refactor(webapp): vue3 migration - button - icon + circle + loading (#9208)

This commit is contained in:
Ulf Gebhardt 2026-02-13 17:27:33 +01:00 committed by GitHub
parent 91fac6f7c6
commit 794b4dabfa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
143 changed files with 4230 additions and 1936 deletions

View File

@ -2,6 +2,6 @@ import { defineStep } from '@badeball/cypress-cucumber-preprocessor'
defineStep('I open the content menu of post {string}', (title) => {
cy.contains('.post-teaser', title)
.find('.content-menu .base-button')
.find('[data-test="content-menu-button"]')
.click()
})

View File

@ -2,7 +2,7 @@ import { defineStep } from '@badeball/cypress-cucumber-preprocessor'
defineStep('I click on "Report Post" from the content menu of the post', () => {
cy.contains('.base-card', 'The Truth about the Holocaust')
.find('.content-menu .base-button')
.find('[data-test="content-menu-button"]')
.click()
cy.get('.popover .ds-menu-item-link')

View File

@ -1,6 +1,6 @@
import { defineStep } from '@badeball/cypress-cucumber-preprocessor'
defineStep('I should see the {string} button', button => {
cy.get('.base-card .action-buttons .base-button')
cy.get('.base-card .action-buttons button')
.should('contain', button)
})

View File

@ -1,7 +1,7 @@
import { defineStep } from '@badeball/cypress-cucumber-preprocessor'
defineStep('I {string} see {string} from the content menu in the user info box', (condition, link) => {
cy.get('.user-content-menu .base-button').click()
cy.get('.user-content-menu [data-test="content-menu-button"]').click()
cy.get('.popover .ds-menu-item-link')
.should(condition === 'should' ? 'contain' : 'not.contain', link)
})

View File

@ -1,6 +1,6 @@
import { defineStep } from '@badeball/cypress-cucumber-preprocessor'
defineStep('I delete a social media link', () => {
cy.get(".base-button[title='Delete']")
cy.get("button[title='Delete']")
.click()
})

View File

@ -2,7 +2,7 @@ import { defineStep } from '@badeball/cypress-cucumber-preprocessor'
defineStep('I click on {string} from the content menu in the user info box',
button => {
cy.get('.user-content-menu .base-button').click()
cy.get('.user-content-menu [data-test="content-menu-button"]').click()
cy.get('.popover .ds-menu-item-link')
.contains(button)
.click({

View File

@ -1,4 +1,5 @@
@import "tailwindcss";
@import "../src/styles/animations.css";
/* Watercolor theme for Storybook - vibrant colors inspired by aquarelle paints */
/* All colors meet WCAG AA contrast requirements (4.5:1 for normal text) */

View File

@ -10,9 +10,9 @@
### Übersicht
```
Phase 0: Analyse ██████████ 100% (8/8 Schritte)
Phase 3: Migration ████░░░░░░ 36% (32/90 Buttons)
Phase 3: Migration ██████████ 100% (132/132 Buttons) ✅
───────────────────────────────────────────
Aktuelle Phase: Phase 3 - Milestone 4a ✅, nächster: Milestone 4b
Phase 3 ABGESCHLOSSEN: M4a ✅, M4b ✅, M4c ✅ — 0 <base-button> verbleibend
```
### Statistiken
@ -25,108 +25,30 @@ Aktuelle Phase: Phase 3 - Milestone 4a ✅, nächster: Milestone 4b
| Duplikate gefunden | 5 direkte + 3 Familien |
| Zur Migration priorisiert | 15 Kern-Komponenten |
### OsButton Migration (Phase 3)
### OsButton Migration (Phase 3)
| Status | Anzahl | Details |
|--------|--------|---------|
| ✅ Migriert | 32 | Erste Welle (16) + Milestone 4a (14) + NotificationMenu (2) |
| ⏳ Ausstehend (mit neuen Props) | ~60 | Milestone 4c (benötigen icon/circle/loading) |
| **Gesamt** | **~90** | In ~50 Dateien |
| ✅ Migriert | 132 | 78 Dateien, alle `<base-button>` ersetzt |
| ⬜ Verbleibend | 0 | Nur BaseButton.vue Definition + Test/Snapshot-Dateien |
| **Gesamt** | **132** | **100% erledigt** |
**Migrierte Komponenten (32):**
**Alle 132 Buttons migriert in 78 Dateien ✅**
*Erste Welle (16):*
- UserTeaserPopover.vue (1 Button)
- GroupForm.vue (1 Button - Cancel)
- EmbedComponent.vue (2 Buttons - Cancel, Play Now)
- DonationInfo.vue (1 Button)
- CommentCard.vue (1 Button - Show More)
- MapStylesButtons.vue (1 Button)
- GroupMember.vue (1 Button)
- embeds.vue (2 Buttons)
- notifications.vue (3 Buttons)
- privacy.vue (1 Button)
- terms-and-conditions-confirm.vue (2 Buttons)
Migration vollständig abgeschlossen. 0 `<base-button>` Tags verbleiben in Vue-Templates.
*Milestone 4a (14) ✅:*
- ✅ DisableModal.vue (1 Button - Cancel)
- ✅ DeleteUserModal.vue (1 Button - Cancel)
- ✅ ReleaseModal.vue (1 Button - Cancel)
- ✅ ContributionForm.vue (1 Button - Cancel)
- ✅ EnterNonce.vue (1 Button - Submit)
- ✅ MySomethingList.vue (1 Button - Cancel)
- ✅ ImageUploader.vue (2 Buttons - Crop)
- ✅ admin/donations.vue (1 Button - Save)
- ✅ profile/_id/_slug.vue (2 Buttons - Unblock, Unmute)
- ✅ settings/badges.vue (1 Button - Remove)
- ✅ notifications/index.vue (1 Button - Mark All Read)
- ✅ ReportRow.vue (1 Button - More Details)
**Erkenntnisse aus der Migration:**
- `type="submit"` muss explizit gesetzt werden (OsButton Default: `type="button"`)
- DsForm `errors` ist ein Objekt → `!!errors` für Boolean-Cast bei `:disabled`
- CSS `.base-button` Selektoren → `> button` oder `button`
- Position/Dimensions brauchen `!important` für Tailwind-Override
- Filter-Buttons nutzen `:appearance="condition ? 'filled' : 'outline'"` Pattern
- Circle-Buttons mit Icon: `<template #icon><base-icon :name="..." /></template>`
*Sonstige (2):*
- ✅ NotificationMenu.vue (2 Buttons - Mark All Read, Notification Page)
**Ausstehend - benötigen neue Props (~60):**
*Button-Komponenten mit icon/circle/loading:*
- ActionButton.vue: 3 Buttons (icon, circle)
- LabeledButton.vue: 1 Button (icon)
- MenuBarButton.vue: 1 Button (icon)
- EmotionButton.vue: 1 Button (icon)
- ShoutButton.vue: 1 Button (icon)
- FollowButton.vue: 1 Button (icon, loading)
- JoinLeaveButton.vue: 1 Button (icon, loading)
- ObserveButton.vue: 1 Button (icon, loading)
- InviteButton.vue: 1 Button (icon, loading)
- MapButton.vue: 1 Button (icon)
- PaginationButtons.vue: 2 Buttons (icon, circle)
*Navigation mit icon:*
- LocaleSwitch.vue: 1 Button (icon)
- HeaderMenu.vue: 2 Buttons (icon)
- AvatarMenu.vue: 1 Button (icon, circle)
- NotificationMenu.vue: 1 Button (icon, circle)
- ChatNotificationMenu.vue: 1 Button (icon, circle)
- FilterMenu.vue: 1 Button (icon)
*Editor-Buttons:*
- Editor.vue: ~10 Toolbar-Buttons (icon)
- ContextMenu.vue: 3 Buttons (icon)
- LinkInput.vue: 2 Buttons (icon, circle)
*Filter-Buttons:*
- CategoriesFilter.vue: 1 Button (icon)
- HashtagsFilter.vue: 1 Button (icon)
- DropdownFilter.vue: 1 Button (icon)
- FilterMenuSection.vue: 2 Buttons (icon)
- DateTimeRange.vue: 2 Buttons (icon)
*Chat-Buttons:*
- Chat.vue: 2 Buttons (icon)
- AddChatRoomByUserSearch.vue: 1 Button (icon, circle)
*Form-Buttons:*
- CommentForm.vue: 1 Button (icon, loading)
- SearchField.vue: 1 Button (icon, circle)
- ShowPassword.vue: 1 Button (icon)
*Modal-Buttons:*
- ConfirmModal.vue: 1 Button (loading)
- ReportModal.vue: 1 Button (loading)
*Feature-Buttons:*
- CreateInvitation.vue: 1 Button (icon)
- Invitation.vue: 1 Button (icon, circle)
- SocialMediaListItem.vue: 1 Button (icon, circle)
- Request.vue: 1 Button (icon, loading)
- GroupButton.vue: 1 Button (icon)
- CtaJoinLeaveGroup.vue: 1 Button (icon)
- data-download.vue: 1 Button (icon, loading)
- AddGroupMember.vue: 1 Button (icon)
- GroupForm.vue Submit: 1 Button (icon)
*Page-Buttons:*
- CommentCard.vue Reply: 1 Button (icon, circle)
- EmbedComponent.vue Close: 1 Button (icon, circle)
- PostTeaser.vue: 2 Buttons (icon)
**Verbleibende Cleanup-Aufgaben:**
- [ ] Snapshot-Dateien aktualisieren (enthalten noch `base-button` Referenzen)
- [ ] Test-Dateien aktualisieren (Selektoren `.base-button``button` oder `os-button-stub`)
- [ ] BaseButton.vue Komponente ggf. entfernen (wenn nicht mehr referenziert)
- [ ] CSS-Selektor `.base-button` in ImageUploader.vue entfernen
---
@ -154,7 +76,7 @@ Aktuelle Phase: Phase 3 - Milestone 4a ✅, nächster: Milestone 4b
### Data Input
| # | Komponente | Status | Webapp-Duplikat | Varianten | Priorität | Notizen |
|---|------------|--------|-----------------|-----------|-----------|---------|
| 13 | Button | ⏳ Migration | BaseButton, CustomButton, ActionButton, ... | | | → OsButton (16/90 migriert) |
| 13 | Button | ⏳ Migration | BaseButton, CustomButton, ActionButton, ... | | | → OsButton (41/90 migriert) |
| 14 | CopyField | ⬜ Ausstehend | | | | |
| 15 | Form | ⬜ Ausstehend | | | | |
| 16 | FormItem | ⬜ Ausstehend | | | | |
@ -219,7 +141,7 @@ Aktuelle Phase: Phase 3 - Milestone 4a ✅, nächster: Milestone 4b
| 7 | BadgeSelection | ⬜ Ausstehend | Input | | |
| 8 | Badges | ⬜ Ausstehend | Display | | |
| 9 | BadgesSection | ⬜ Ausstehend | Display | | |
| 10 | BaseButton | ⏳ Migration | Button | Button | 🔄 → OsButton (16/90 migriert) |
| 10 | BaseButton | ⏳ Migration | Button | Button | 🔄 → OsButton (41/90 migriert) |
| 11 | BaseCard | ⬜ Ausstehend | Layout | Card | 🔗 DUPLIKAT |
| 12 | BaseIcon | ⬜ Ausstehend | Display | Icon | 🔗 DUPLIKAT |
@ -228,7 +150,7 @@ Aktuelle Phase: Phase 3 - Milestone 4a ✅, nächster: Milestone 4b
|---|------------|--------|-----------|-------------------|---------|
| 13 | CategoriesFilter | ⬜ Ausstehend | Filter | | |
| 14 | CategoriesMenu | ⬜ Ausstehend | Navigation | Menu | |
| 15 | CategoriesSelect | ⬜ Ausstehend | Input | Select | |
| 15 | CategoriesSelect | ⏳ Teilweise | Input | Select | Buttons → OsButton (icon) |
| 16 | ChangePassword | ⬜ Ausstehend | Feature | | Auth-spezifisch |
| 17 | Change | ⬜ Ausstehend | Feature | | |
| 18 | Chat | ⬜ Ausstehend | Feature | | Chat-spezifisch |
@ -241,12 +163,12 @@ Aktuelle Phase: Phase 3 - Milestone 4a ✅, nächster: Milestone 4b
| 25 | ContentMenu | ⬜ Ausstehend | Navigation | Menu | |
| 26 | ContentViewer | ⬜ Ausstehend | Display | | |
| 27 | ContextMenu | ⬜ Ausstehend | Navigation | Menu | |
| 28 | ContributionForm | ⬜ Ausstehend | Feature | Form | Post-spezifisch |
| 28 | ContributionForm | ⏳ Teilweise | Feature | Form | Cancel → OsButton |
| 29 | CounterIcon | ⬜ Ausstehend | Display | Icon | |
| 30 | CountTo | ⬜ Ausstehend | Display | Number | Animation |
| 31 | CreateInvitation | ⬜ Ausstehend | Feature | | |
| 32 | CtaJoinLeaveGroup | ⬜ Ausstehend | Button | Button | 🔄 Button-Familie |
| 33 | CtaUnblockAuthor | ⬜ Ausstehend | Button | Button | 🔄 Button-Familie |
| 33 | CtaUnblockAuthor | ✅ Migriert | Button | Button | Button → OsButton (icon) |
| 34 | CustomButton | ⬜ Ausstehend | Button | Button | 🔄 Button-Familie |
### D-E
@ -254,8 +176,8 @@ Aktuelle Phase: Phase 3 - Milestone 4a ✅, nächster: Milestone 4b
|---|------------|--------|-----------|-------------------|---------|
| 35 | DateTimeRange | ⬜ Ausstehend | Input | | |
| 36 | DeleteData | ⬜ Ausstehend | Feature | | |
| 37 | DeleteUserModal | ⬜ Ausstehend | Feedback | Modal | 🔄 Modal-Familie |
| 38 | DisableModal | ⬜ Ausstehend | Feedback | Modal | 🔄 Modal-Familie |
| 37 | DeleteUserModal | ⏳ Teilweise | Feedback | Modal | 🔄 Modal-Familie, Buttons → OsButton |
| 38 | DisableModal | ⏳ Teilweise | Feedback | Modal | 🔄 Modal-Familie, Buttons → OsButton |
| 39 | DonationInfo | ✅ Migriert | Display | | Button → OsButton |
| 40 | Dropdown | ⬜ Ausstehend | Input | Select | |
| 41 | DropdownFilter | ⬜ Ausstehend | Filter | Select | |
@ -265,7 +187,7 @@ Aktuelle Phase: Phase 3 - Milestone 4a ✅, nächster: Milestone 4b
| 45 | EmotionButton | ⬜ Ausstehend | Button | Button | |
| 46 | Emotions | ⬜ Ausstehend | Feature | | |
| 47 | Empty | ⬜ Ausstehend | Feedback | Placeholder | |
| 48 | EnterNonce | ⬜ Ausstehend | Feature | | Auth |
| 48 | EnterNonce | ⏳ Teilweise | Feature | | Auth, Submit → OsButton |
### F-G
| # | Komponente | Status | Kategorie | Styleguide-Pendant | Notizen |
@ -293,7 +215,7 @@ Aktuelle Phase: Phase 3 - Milestone 4a ✅, nächster: Milestone 4b
| 65 | HashtagsFilter | ⬜ Ausstehend | Filter | | |
| 66 | HeaderButton | ⬜ Ausstehend | Button | Button | 🔄 Button-Familie |
| 67 | HeaderMenu | ⬜ Ausstehend | Navigation | Menu | |
| 68 | ImageUploader | ⬜ Ausstehend | Input | | |
| 68 | ImageUploader | ⏳ Teilweise | Input | | Crop-Buttons → OsButton |
| 69 | index | ⬜ Ausstehend | ? | | Prüfen |
| 70 | InternalPage | ⬜ Ausstehend | Layout | Page | |
| 71 | Invitation | ⬜ Ausstehend | Feature | | |
@ -305,7 +227,7 @@ Aktuelle Phase: Phase 3 - Milestone 4a ✅, nächster: Milestone 4b
| 77 | LoadingSpinner | ⬜ Ausstehend | Feedback | Spinner | 🔗 DUPLIKAT |
| 78 | LocaleSwitch | ⬜ Ausstehend | Navigation | | |
| 79 | LocationInfo | ⬜ Ausstehend | Display | | |
| 80 | LocationSelect | ⬜ Ausstehend | Input | Select | |
| 80 | LocationSelect | ⏳ Teilweise | Input | Select | Close-Button → OsButton (icon) |
| 81 | LocationTeaser | ⬜ Ausstehend | Display | Card | |
| 82 | LoginButton | ⬜ Ausstehend | Button | Button | |
| 83 | LoginForm | ⬜ Ausstehend | Feature | Form | Auth |
@ -322,8 +244,8 @@ Aktuelle Phase: Phase 3 - Milestone 4a ✅, nächster: Milestone 4b
| 90 | MenuBarButton | ⬜ Ausstehend | Button | Button | 🔄 Button-Familie |
| 91 | MenuLegend | ⬜ Ausstehend | Navigation | | |
| 92 | Modal | ⬜ Ausstehend | Feedback | Modal | 🔗 DUPLIKAT |
| 93 | MySomethingList | ⬜ Ausstehend | Display | List | |
| 94 | NotificationMenu | ⬜ Ausstehend | Navigation | Menu | |
| 93 | MySomethingList | ⏳ Teilweise | Display | List | Cancel → OsButton |
| 94 | NotificationMenu | ⏳ Teilweise | Navigation | Menu | 2/3 Buttons → OsButton |
| 95 | NotificationsTable | ⬜ Ausstehend | Display | Table | |
| 96 | ObserveButton | ⬜ Ausstehend | Button | Button | |
| 97 | OrderByFilter | ⬜ Ausstehend | Filter | | |
@ -333,7 +255,7 @@ Aktuelle Phase: Phase 3 - Milestone 4a ✅, nächster: Milestone 4b
|---|------------|--------|-----------|-------------------|---------|
| 98 | PageFooter | ⬜ Ausstehend | Layout | | |
| 99 | PageParamsLink | ⬜ Ausstehend | Navigation | | |
| 100 | PaginationButtons | ⬜ Ausstehend | Navigation | | |
| 100 | PaginationButtons | ✅ Migriert | Navigation | | 2 Buttons → OsButton (icon, circle) |
| 101 | PostTeaser | ⬜ Ausstehend | Display | Card | |
| 102 | PostTypeFilter | ⬜ Ausstehend | Filter | | |
| 103 | ProfileAvatar | ⬜ Ausstehend | Display | Avatar | |
@ -345,10 +267,10 @@ Aktuelle Phase: Phase 3 - Milestone 4a ✅, nächster: Milestone 4b
| 109 | RegistrationSlideNonce | ⬜ Ausstehend | Feature | | Auth |
| 110 | RegistrationSlideNoPublic | ⬜ Ausstehend | Feature | | Auth |
| 111 | RegistrationSlider | ⬜ Ausstehend | Feature | | Auth |
| 112 | ReleaseModal | ⬜ Ausstehend | Feedback | Modal | 🔄 Modal-Familie |
| 112 | ReleaseModal | ⏳ Teilweise | Feedback | Modal | 🔄 Modal-Familie, Cancel → OsButton |
| 113 | ReportList | ⬜ Ausstehend | Display | List | |
| 114 | ReportModal | ⬜ Ausstehend | Feedback | Modal | 🔄 Modal-Familie |
| 115 | ReportRow | ⬜ Ausstehend | Display | | |
| 115 | ReportRow | ⏳ Teilweise | Display | | More Details → OsButton |
| 116 | ReportsTable | ⬜ Ausstehend | Display | Table | |
| 117 | Request | ⬜ Ausstehend | Feature | | |
| 118 | ResponsiveImage | ⬜ Ausstehend | Display | | |
@ -484,6 +406,9 @@ Diese sollten zuerst migriert werden:
| 2026-02-08 | Claude | OsButton erweitert | attrs/listeners Forwarding für Vue 2 ($listeners) |
| 2026-02-09 | Claude | Scope erweitert | ~90 Buttons identifiziert (16 migriert, 14 ohne Props, ~60 mit Props) |
| 2026-02-09 | Claude | **Milestone 4a: 8 Buttons** | DisableModal, DeleteUserModal, ReleaseModal, ContributionForm, EnterNonce, MySomethingList, ImageUploader (2x) |
| 2026-02-09 | Claude | **Milestone 4a abgeschlossen** | 6 weitere: donations, profile (2x), badges, notifications/index, ReportRow |
| 2026-02-11 | Claude | **M4b: icon + circle** | icon-Slot implementiert, circle-Prop mit CVA |
| 2026-02-11 | Claude | **9 icon-Buttons migriert (M4c)** | DisableModal, DeleteUserModal, CtaUnblockAuthor, LocationSelect, CategoriesSelect, my-email-address, profile Chat, PaginationButtons (2x circle) |
---
@ -504,18 +429,18 @@ Diese sollten zuerst migriert werden:
10. [x] Webapp-Integration (Jest, Docker, CI)
11. [x] 16 Buttons migrieren (validiert ✅)
**Milestone 4a: 14 Buttons ohne neue Props**
12. [ ] Modal Cancel-Buttons (3)
13. [ ] Form Cancel/Submit-Buttons (3)
14. [ ] ImageUploader Crop-Buttons (2)
15. [ ] Page Buttons (6)
**Milestone 4a: 14 Buttons ohne neue Props**
12. [x] Modal Cancel-Buttons (3)
13. [x] Form Cancel/Submit-Buttons (3)
14. [x] ImageUploader Crop-Buttons (2)
15. [x] Page Buttons (6)
**Milestone 4b: Props für ~60 Buttons hinzufügen**
16. [ ] icon-Prop zu OsButton hinzufügen
17. [ ] circle-Variant zu OsButton hinzufügen
**Milestone 4b: Props für ~49 Buttons hinzufügen**
16. [x] icon-Slot zu OsButton hinzufügen ✅
17. [x] circle-Variant zu OsButton hinzufügen ✅
18. [ ] loading-Prop zu OsButton hinzufügen
**Milestone 4c: ~60 Buttons mit neuen Props migrieren**
**Milestone 4c: ~49 Buttons mit neuen Props migrieren**
19. [ ] Button-Komponenten (~15)
20. [ ] Navigation (~8)
21. [ ] Editor (~15)
@ -1213,7 +1138,7 @@ $box-shadow-small-inset: inset 0 0 0 1px rgba(0,0,0,.05)
## Phase 3: Webapp-Integration (Tracking)
### OsButton Migration - Abgeschlossen (16/90)
### OsButton Migration - Abgeschlossen (132/132) ✅
| # | Datei | Button | Status |
|---|-------|--------|--------|
@ -1234,70 +1159,34 @@ $box-shadow-small-inset: inset 0 0 0 1px rgba(0,0,0,.05)
| 15 | terms-and-conditions-confirm.vue | Read T&C | ✅ Migriert |
| 16 | terms-and-conditions-confirm.vue | Save | ✅ Migriert |
### OsButton Migration - Ausstehend ohne neue Props (Milestone 4a: 14/90)
### OsButton Migration - Alle Milestones abgeschlossen ✅
| # | Datei | Button | OsButton Props | Status |
|---|-------|--------|----------------|--------|
| 17 | Modal/DisableModal.vue | Cancel | `default` | ⬜ Ausstehend |
| 18 | Modal/DeleteUserModal.vue | Cancel | `default` | ⬜ Ausstehend |
| 19 | Modal/ReleaseModal.vue | Cancel | `default` | ⬜ Ausstehend |
| 20 | ContributionForm.vue | Cancel | `:disabled` | ⬜ Ausstehend |
| 21 | EnterNonce.vue | Submit | `variant="primary" :disabled` | ⬜ Ausstehend |
| 22 | MySomethingList.vue | Cancel | `default` | ⬜ Ausstehend |
| 23 | ImageUploader.vue | Crop Confirm 1 | `variant="primary"` | ⬜ Ausstehend |
| 24 | ImageUploader.vue | Crop Confirm 2 | `variant="primary"` | ⬜ Ausstehend |
| 25 | admin/donations.vue | Save | `variant="primary"` | ⬜ Ausstehend |
| 26 | profile/_id/_slug.vue | Unblock | `default` | ⬜ Ausstehend |
| 27 | profile/_id/_slug.vue | Unmute | `default` | ⬜ Ausstehend |
| 28 | settings/badges.vue | Remove | `default` | ⬜ Ausstehend |
| 29 | notifications/index.vue | Mark All Read | `variant="primary" :disabled` | ⬜ Ausstehend |
| 30 | ReportRow.vue | More Details | `size="sm"` | ⬜ Ausstehend |
| Milestone | Status | Details |
|-----------|--------|---------|
| M4a: Buttons ohne neue Props | ✅ | 14 Buttons (Modals, Forms, Pages) |
| M4b: Props implementieren | ✅ | icon (Slot), circle, loading |
| M4c: Buttons mit icon/circle/loading | ✅ | Alle verbleibenden Buttons migriert |
### OsButton Migration - Ausstehend mit neuen Props (Milestone 4c: ~60/90)
### OsButton Features - Alle implementiert ✅
> Diese Buttons benötigen icon, circle, und/oder loading Props.
> Siehe "Ausstehend - benötigen neue Props (~60)" oben für vollständige Liste.
**Kategorien:**
| Kategorie | Anzahl | Props benötigt |
|-----------|--------|----------------|
| Button-Komponenten | ~15 | icon, circle, loading |
| Navigation | ~8 | icon, circle |
| Editor | ~15 | icon |
| Filter/Chat | ~10 | icon, circle |
| Forms/Modals | ~5 | icon, loading |
| Features/Pages | ~12 | icon, circle, loading |
### Fehlende OsButton-Features
| Feature | Benötigt für | Status |
|---------|-------------|--------|
| `icon` Prop | ~55 Buttons | ⬜ Fehlt |
| `circle` Variant | ~25 Buttons | ⬜ Fehlt |
| `loading` Prop | ~10 Buttons | ⬜ Fehlt |
| `appearance="outline"` | ✅ Implementiert | ✅ Erledigt |
| `appearance="ghost"` | ✅ Implementiert | ✅ Erledigt |
| Feature | Status |
|---------|--------|
| `variant` (7 Varianten) | ✅ |
| `appearance` (filled/outline/ghost) | ✅ |
| `size` (xs/sm/md/lg/xl) | ✅ |
| `icon` Slot | ✅ |
| `circle` Prop | ✅ |
| `loading` Prop | ✅ |
| `disabled` mit hover/active-Override | ✅ |
| `fullWidth` Prop | ✅ |
| `type` Prop (button/submit/reset) | ✅ |
### Nächste Schritte
**Milestone 4a: 14 Buttons ohne neue Props migrieren**
1. Modal Cancel-Buttons (3)
2. Form Cancel/Submit-Buttons (3)
3. ImageUploader Crop-Buttons (2)
4. Page Buttons (6)
**Milestone 4b: Props für ~60 Buttons hinzufügen**
1. Icon-Prop zu OsButton hinzufügen
2. Circle-Variant zu OsButton hinzufügen
3. Loading-Prop zu OsButton hinzufügen
**Milestone 4c: ~60 Buttons mit neuen Props migrieren**
1. Button-Komponenten (~15)
2. Navigation (~8)
3. Editor (~15)
4. Filter/Chat (~10)
5. Forms/Modals (~5)
6. Features/Pages (~12)
1. Snapshot-Dateien aktualisieren
2. Test-Selektoren anpassen
3. BaseButton.vue ggf. entfernen
4. Phase 4: Weitere Komponenten (OsIcon, OsCard, OsModal, ...)
### Integrations-Protokoll

View File

@ -80,11 +80,11 @@
Phase 0: ██████████ 100% (6/6 Aufgaben) ✅
Phase 1: ██████████ 100% (6/6 Aufgaben) ✅
Phase 2: ██████████ 100% (26/26 Aufgaben) ✅
Phase 3: █████████░ 83% (20/24 Aufgaben) - Webapp-Integration (M4a ✅, M5 ✅)
Phase 3: ██████████ 100% (24/24 Aufgaben) ✅ - Webapp-Integration komplett
Phase 4: █░░░░░░░░░ 6% (1/17 Aufgaben) - OsButton ✅
Phase 5: ░░░░░░░░░░ 0% (0/7 Aufgaben)
───────────────────────────────────────
Gesamt: ███████░░░ 69% (59/86 Aufgaben)
Gesamt: ████████░░ 74% (63/86 Aufgaben)
```
### Katalogisierung (Details in KATALOG.md)
@ -94,30 +94,31 @@ Styleguide: ██████████ 100% (38 Komponenten erfasst)
Analyse: ██████████ 100% (Button, Modal, Menu detailiert)
```
### OsButton Migration (Phase 3)
### OsButton Migration (Phase 3)
```
Scope gesamt: ~90 Buttons in Webapp
├─ Migriert: 32 Buttons (36%) ✅
├─ Ohne neue Props: 0 Buttons (Milestone 4a ✅)
└─ Mit icon/circle/loading: ~60 Buttons (Milestone 4c)
Scope gesamt: 133 <os-button> Tags in 79 Webapp-Dateien
├─ Migriert: 133 Buttons (100%) ✅
├─ <base-button>: 0 verbleibend in Templates
├─ <ds-button>: 0 verbleibend in Templates
└─ Cleanup: Snapshots/Tests müssen aktualisiert werden
OsButton Features:
├─ variant: ✅ primary, secondary, danger, warning, success, info, default
├─ appearance: ✅ filled, outline, ghost
├─ size: ✅ xs, sm, md, lg, xl
├─ size: ✅ sm, md, lg, xl
├─ disabled: ✅ mit hover/active-Override
├─ icon: ⬜ TODO (Milestone 4b)
├─ circle: ⬜ TODO (Milestone 4b)
└─ loading: ⬜ TODO (Milestone 4b)
├─ icon: ✅ slot-basiert (icon-system-agnostisch)
├─ circle: ✅ rounded-full, größenabhängig (p-1.5 bis p-3)
└─ loading: ✅ animated SVG spinner, aria-busy (Milestone 4b)
```
---
## Aktueller Stand
**Letzte Aktualisierung:** 2026-02-10 (Session 12)
**Letzte Aktualisierung:** 2026-02-13 (Session 19)
**Aktuelle Phase:** Phase 3 (Webapp-Integration) - Milestone 4a abgeschlossen ✅ (32 Buttons migriert, nächster: Milestone 4b)
**Aktuelle Phase:** Phase 3 ✅ ABGESCHLOSSEN + Code-Review-Feedback eingearbeitet
**Zuletzt abgeschlossen:**
- [x] Projektordner erstellt
@ -183,11 +184,171 @@ OsButton Features:
- Completeness Check (verify Script prüft Story, Visual, checkA11y, Keyboard, Varianten)
- ESLint Plugins: vuejs-accessibility, playwright, storybook, jsdoc
**Aktuell in Arbeit:**
- Phase 3, Milestone 4b: icon/circle/loading Props in OsButton implementieren
- Phase 3, Milestone 4c: ~60 Buttons mit icon/circle/loading migrieren
**Zuvor abgeschlossen (Session 18 - CodeRabbit Review Feedback: data-test Selektoren, Accessibility, Bugfixes):**
- [x] Cypress-Selektoren: `.user-content-menu button``[data-test="content-menu-button"]` (2 Step-Definitions)
- [x] Cypress-Selektoren: `.content-menu button``[data-test="content-menu-button"]` (Admin.PinPost + ReportContent)
- [x] muted-users.vue: `data-test="unmute-btn"` + `aria-label` auf Unmute-Button
- [x] blocked-users.vue: `data-test="unblock-btn"` + `aria-label` auf Unblock-Button
- [x] ProfileList.vue: `data-test="load-all-connections-btn"` + FollowList.spec.js Selektoren aktualisiert
- [x] FollowButton.vue: `data-test="follow-btn"` + Spec-Selektoren aktualisiert
- [x] JoinLeaveButton.vue: `data-test="join-leave-btn"` + `.native` von `@mouseenter`/`@mouseleave` entfernt
- [x] LoginButton.vue: `data-test="login-btn"` + `aria-label="$t('login.login')"` + Spec-Selektoren aktualisiert
- [x] ReportRow.spec.js: `button[data-variant="danger"]``[data-test="confirm"]`
- [x] CtaJoinLeaveGroup.spec.js: Selektor auf `[data-test="join-leave-btn"]` aktualisiert
- [x] DisableModal.vue: `finally { this.loading = false }` für Loading-State-Reset
- [x] ReleaseModal.vue: `:loading="loading"` + `this.loading = true` + `finally { this.loading = false }`
- [x] ChangePassword.vue: `:disabled="errors"``:disabled="!!errors"` (Boolean-Cast)
- [x] Password/Change.vue: Unbenutzte `disabled: true` aus data() entfernt + 2 tote Tests entfernt
- [x] MenuBar.vue: Unbenutztes `ref="linkButton"` entfernt
- [x] GroupForm.vue: Cancel-Button `variant="default" appearance="filled"` (per User-Anweisung)
- [x] `appearance="filled"` ergänzt: donations.vue, LoginForm.vue, EnterNonce.vue
- [x] LoginForm.vue: CSS `.login-form button``.login-form button[type='submit']`
- [x] pages/index.vue: Redundantes `class="my-filter-button"` von `<base-icon>` entfernt
- [x] MySomethingList.vue: `:title` + `:aria-label` auf Edit/Delete-Buttons (Tooltip beibehalten)
- [x] A11y aria-label auf icon-only Buttons: admin/users (search + edit), AddChatRoomByUserSearch (close), EmbedComponent (close), groups/index (create), profile/_id/_slug (new post), groups/_id/_slug (new post), CustomButton (2x tooltip), HeaderMenu (hamburger), ImageUploader (crop-cancel), ContentMenu (menu), HeaderButton (filter-remove), InviteButton (invite)
- [x] post/_id/_slug/index.vue: Zustandsabhängiges `aria-label` (`post.sensitiveContent.show/hide`)
- [x] ComponentSlider.vue: `aria-label` mit Interpolation (`component-slider.step`)
- [x] i18n: `actions.search`, `actions.close`, `actions.menu` in allen 9 Sprachdateien
- [x] i18n: `site.navigation` in allen 9 Sprachdateien
- [x] i18n: `post.sensitiveContent.show/hide` in allen 9 Sprachdateien
- [x] i18n: `component-slider.step` in allen 9 Sprachdateien
**Zuletzt abgeschlossen (Session 12 - CSS-Linting, CI-Optimierung, Code-Review Fixes):**
**Zuletzt abgeschlossen (Session 19 - CodeRabbit Review Feedback: Cleanup, Accessibility, Bugfixes):**
- [x] donations.vue: Redundantes `:checked="showDonations"` entfernt (v-model setzt checked bereits)
- [x] MySomethingList.vue: Disabled-Logik vereinfacht `!(!isEditing || (isEditing && !disabled))``isEditing && disabled`
- [x] button.variants.ts: Hardcoded Fallback `#e5e3e8` entfernt → `var(--color-disabled)` (konsistent mit filled/index.css)
- [x] CommentCard.vue: `aria-label` auf icon-only Reply-Button
- [x] HashtagsFilter.vue: `aria-label` auf icon-only Clear-Button
- [x] ReleaseModal.vue: `$emit('close')` im catch-Block ergänzt (fehlte im Fehlerfall)
- [x] Chat.vue: `aria-label` auf Expand- und Close-Buttons
- [x] i18n: `chat.expandChat` + `chat.closeChat` in allen 9 Sprachdateien (vollständig übersetzt)
- [x] ChatNotificationMenu.vue: `aria-label` auf icon-only Chat-Button
- [x] SearchableInput.vue: `aria-label` auf icon-only Close-Button
- [x] GroupButton.vue: `aria-label` auf icon-only Groups-Button
- [x] MapButton.vue: `aria-label` auf icon-only Map-Button
- [x] EmotionButton.vue: `aria-label` auf icon-only Emoji-Button (`<label for>` wirkt nicht auf `<button>`)
- [x] ImageUploader.vue: `aria-label` auf icon-only Delete-Button
- [x] i18n: `actions.clear` disambiguiert — es: "Borrar" → "Limpiar", it: "Cancella" → "Svuota" (Verwechslung mit `actions.delete`)
- [x] OsButton.vue: `as string` Cast bei `attrClass` entfernt (cn/clsx verarbeitet Arrays/Objekte korrekt)
- [x] CommentForm.vue: `handleSubmit` auf async/await + try/catch/finally umgestellt (Loading-Bug im Fehlerfall behoben)
- [x] MenuBar.vue: `aria-label` auf alle 11 Editor-Toolbar-Buttons (nutzt bestehende `editor.legend.*` Keys)
- [x] NotificationMenu.vue: `aria-label` auf alle 3 Bell-Buttons
- [x] NotificationMenu.vue: `counter-icon` von Default-Slot in `#icon`-Slot verschoben (2 Stellen, Rendering-Bug)
- [x] ChatNotificationMenu.vue: `counter-icon` von Default-Slot in `#icon`-Slot verschoben (Rendering-Bug)
- [x] InviteButton.vue: `this.currentUser``this.user` (Bug: Getter hieß `user`, `currentUser` war undefined)
- [x] pages/index.vue: `beforeDestroy()` aus `methods` in Lifecycle-Hook verschoben (Memory-Leak: Event-Listener wurden nie entfernt)
- [x] Editor.vue: Fehlender `else`-Branch in `toggleLinkInput()``isLinkInputActive` wird jetzt auch bei no-args-Aufrufen (blur/esc) zurückgesetzt
- [x] admin/users/index.vue: Veraltete Slot-Syntax `slot="role" slot-scope="scope"``#role="scope"` (Vue 3)
- [x] settings/index.vue: Irreführender Komponentenname `NewsFeed``Settings`
- [x] FilterMenu.spec.js: Typo `dropdwon``dropdown` im Testnamen
- [x] ImageUploader.vue: `:title` auf Crop-Cancel-Button ergänzt (konsistent mit Delete-Button)
- [x] OsButton.spec.ts: `as const` auf `sizes`-Objekt statt Type-Cast bei jedem `mount`-Aufruf
- [x] CommentForm.vue: `disabled = false` aus `finally` in `catch` verschoben (verhindert Überschreiben nach `clear()`)
- [x] FilterMenu.vue: `aria-label` auf icon-only Filter-Button
- [x] ContextMenu.vue: `this.menu.show()` nur bei `type !== 'link'` (Link-Menüs öffneten sich sofort statt auf Klick zu warten)
- [x] ContextMenu.vue: `this.menu = null` vor `destroy()` (Race-Condition: ESC + blur feuerten doppelt → removeChild-Error)
- [x] CustomButton.vue: `variant="primary"` auf beide `os-button`-Instanzen (Konsistenz mit restlicher Codebase)
- [x] Invitation.vue: Ungenutztes Argument `inviteCode.copy` bei `copyInviteCode()` entfernt
- [x] CtaUnblockAuthor.vue: `appearance="filled"` explizit gesetzt (fehlte als einziger primärer CTA)
- [x] HeaderMenu.vue: `beforeDestroy`-Hook ergänzt — Scroll-Listener wird jetzt entfernt (Memory-Leak)
- [x] MenuLegend.vue: `variant="primary"` auf Trigger-Button (konsistent mit Toolbar-Buttons in MenuBar.vue)
**Zuvor abgeschlossen (Session 18 - Code-Review Feedback, OsButton Refactoring, Accessibility):**
- [x] OsButton.vue vereinfacht: `vueAttrs()` Helper, Einmal-Variablen durch `cn()` ersetzt, `children` Array inline (217→227 Zeilen, aber lesbarer)
- [x] OsButton: `@import "./animations.css"` vor `@source`-Direktiven verschoben (CSS-Spec-Konformität)
- [x] CustomButton.vue: `isEmpty` aus `data()` entfernt → direkter Import im Computed
- [x] notifications.spec.js: Doppelten `beforeEach` konsolidiert, `wrapper` in `describe`-Block verschoben
- [x] MenuLegend.vue: `<style scoped>` hinzugefügt (verhindert Style-Leaking generischer Klassennamen)
- [x] LocationSelect: `data-test="clear-location-button"` + spezifischerer Selektor im Spec
- [x] HashtagsFilter: `data-test="clear-search-button"` + spezifischerer Selektor im Spec
- [x] FollowButton.vue: `.native` Modifier von `@mouseenter`/`@mouseleave` entfernt (Vue 3 Kompatibilität)
- [x] MapButton.vue: Icon in `<template #icon>` verschoben + redundantes Inline-Style entfernt
- [x] MySomethingList.vue: Unbenutzte `.icon-button` CSS-Klasse entfernt
- [x] PaginationButtons.vue: Hardcoded `aria-label``$t('pagination.previous/next')` (i18n)
- [x] `pagination.previous/next` in allen 9 Sprachdateien angelegt
- [x] GroupContentMenu.vue: `aria-label` via `$t('group.contentMenu.menuButton')` für icon-only Button
- [x] `group.contentMenu.menuButton` in allen 9 Sprachdateien angelegt
- [x] FilterMenu.vue: Veraltete `slot="default"` + `slot-scope``<template #default="{ toggleMenu }">` (Vue 3)
- [x] HashtagsFilter.vue: `this.$t()``$t()` im Template (Vue 3 Kompatibilität)
- [x] DisableModal.vue: `appearance="filled"` + `:loading="loading"` auf Danger-Button
- [x] DeleteUserModal.vue: `appearance="filled"` + `:loading="loading"` auf Danger-Button
- [x] my-email-address/index.vue: `loadingData` State + `:loading` auf Submit-Button + `finally` Block
- [x] ReportModal.vue: `class="report-modal"` + CSS-Selektoren mit Prefix (verhindert globales Style-Leaking)
- [x] DeleteUserModal.vue: CSS-Selektoren mit `.delete-user-modal` Prefix (verhindert globales Style-Leaking)
- [x] Button-Wrapper-Analyse: GroupButton + MapButton als Kandidaten zum Inlining identifiziert (nur 1 Nutzungsort, keine Logik)
**Zuvor abgeschlossen (Session 16 - Bugfixes, Code-Review, letzte ds-button Migration):**
- [x] Password/Change.vue: `!!errors` Fix für disabled-Prop
- [x] CommentForm.vue: `type="submit"` + `!!errors` Fix
- [x] GroupForm.vue: Letzter `<ds-button>``<os-button>` migriert (save/update mit icon)
- [x] OsButton.spec.ts: TypeScript-Fix für size-Prop Union Type
- [x] OsButton.vue: v8 ignore Coverage-Fixes (100% Branch Coverage)
- [x] 0 `<ds-button>` und 0 `<base-button>` in Webapp-Templates verbleibend
- [x] `data-variant` Attribut auf OsButton (konsistent mit `data-appearance`, CSS-Selektor-Support)
- [x] notifications.spec.js: `wrapper.find()` → Testing Library `screen.getByText()` (war Vue Test Utils API)
- [x] FilterMenu.vue: Dynamische `:appearance="filterActive ? 'filled' : 'ghost'"` (Regressionsbug)
- [x] FilterMenu.spec.js: `data-appearance="filled"` statt CSS-Klasse `--filled`
- [x] CtaUnblockAuthor.vue: `require``required` Typo-Fix
- [x] LocationSelect.vue: `clearLocationName()` direkt via `this.currentValue` statt `event.target.value`
- [x] LocationSelect.vue: `@click.native``@click` (Vue 3 Kompatibilität)
- [x] LocationSelect.vue: `aria-label` via `$t('actions.clear')` (i18n)
- [x] `actions.clear` in allen 9 Sprachdateien angelegt (en, de, fr, es, it, nl, pl, pt, ru)
- [x] OsButton: JSDoc-Dokumentation für Slots (`@slot default`, `@slot icon`)
- [x] OsButton: `isSmall` von `['xs', 'sm']` auf `size === 'sm'` vereinfacht (xs existiert nicht)
- [x] OsButton: Strikte Typisierung `Record<Size, ...>` statt `Record<string, ...>` für Lookup-Maps
- [x] animations.css: Stylelint-konforme Formatierung (eine Deklaration pro Zeile)
**Zuvor abgeschlossen (Session 15 - Milestone 4c komplett):**
- [x] **Alle verbleibenden base-button Instanzen migriert** (132 os-button Tags, 0 base-button verbleibend)
- [x] 59 Buttons in dieser Session migriert (Chat, Filter, Modals, Forms, Pages, etc.)
- [x] `type="submit"` für alle Form-Buttons (OsButton default ist `type="button"`)
- [x] `!!errors` Boolean-Cast für disabled-Props (errors ist Objekt, nicht Boolean)
- [x] CSS-Selektoren `.base-button``> button` oder `button` angepasst
- [x] `!important` für CSS-Positioning (überschreibt Tailwind-Klassen)
- [x] Disabled outline border-color Fix (`var(--color-disabled-border,#e5e3e8)`)
- [x] ComponentSlider Selection-Dots: dynamic appearance + 18px custom CSS
- [x] pages/index.vue FAB: `size="xl"` + position/dimension `!important`
- [x] pages/groups FAB: `size="xl"` + box-shadow `!important`
- [x] ReportModal Breite auf 700px beibehalten
- [x] ContributionForm Submit: `type="submit"` + `!!errors` Fix
- [x] my-email-address/index.vue: `!!errors` Fix
**Zuvor abgeschlossen (Session 14 - Loading Prop, Circle Prop, Code-Optimierung):**
- [x] `loading` Prop mit animiertem SVG-Spinner implementiert
- [x] Spinner-Architektur: Beide Animationen (rotate + dash) auf `<circle>` Element (Chrome-Compositing-Bug-Workaround)
- [x] Spinner zentriert auf Icon (Icon-Buttons) oder Button-Container (Text-Only-Buttons)
- [x] Icon bleibt bei loading sichtbar, Spinner überlagert Icon-Bereich
- [x] `aria-busy="true"` für Screenreader bei loading
- [x] `circle` Prop implementiert (rounded-full, größenabhängige Breiten)
- [x] `min-width` pro Größe hinzugefügt (verhindert zu kleine leere Buttons)
- [x] Animations-Keyframes in `animations.css` ausgelagert (wiederverwendbar)
- [x] Code-Optimierung: OsButton von ~250 auf 207 Zeilen vereinfacht
- `buttonData` Objekt für Vue 2/3 geteilt
- `SPINNER_PX` vereinfacht (Tuple → einfache Zahlen)
- Redundante `cn()` Wrapping entfernt
- `getCurrentInstance()` nur bei Vue 2 aufgerufen
- [x] 76 Unit-Tests (5 neue: default type, data-appearance, min-w, icon-only loading, circle gap)
- [x] Loading-Stories in Storybook (alle Varianten × Appearances)
- [x] Visual Tests mit `animationPlayState = 'paused'` für stabile Screenshots
- [x] PaginationButtons.vue migriert (2 circle icon-only Buttons)
**Zuvor abgeschlossen (Session 13 - Icon-Slot, Storybook Playground, Webapp-Migration):**
- [x] Icon-Slot für OsButton implementiert (slot-basiert, icon-system-agnostisch)
- [x] Render-Funktion: `slots.icon?.()``<span class="os-button__icon">` Wrapper
- [x] Tailwind-Klassen direkt auf Icon-Wrapper (kein custom CSS in index.css nötig)
- [x] VNode-basierte Text-Erkennung: Whitespace-only = icon-only (gap/margin-Logik)
- [x] Storybook: 4 neue Stories (Icon, IconOnly, IconSizes, IconAppearances)
- [x] Playground: Reaktiver Icon-Selektor (none/check/close/plus) + Label-Text-Control
- [x] Visual Tests: 4 neue Tests mit Screenshots + a11y-Checks
- [x] Unit Tests: 8 neue Tests (icon slot, keyboard a11y mit aria-label)
- [x] Erste Webapp-Migration mit Icon: `my-email-address/index.vue` (Save-Button mit check-Icon)
- [x] Code-Optimierung: ICON_CLASS Konstante, iconMargin Variable, vereinfachte hasText-Logik
- [x] Größenabhängiger Gap: `gap-1` für xs/sm, `gap-2` für md/lg/xl
- [x] Größenabhängiger Icon-Margin: kein negativer Margin bei xs/sm (mehr Abstand zur Button-Grenze)
- [x] 6 weitere Buttons mit Icon migriert: DisableModal, DeleteUserModal, CtaUnblockAuthor, LocationSelect, CategoriesSelect, profile Chat
- [x] verify.vue hat keinen Button (Eintrag korrigiert)
**Zuvor abgeschlossen (Session 12 - CSS-Linting, CI-Optimierung, Code-Review Fixes):**
- [x] CSS-Linting: `@eslint/css` + `tailwind-csstree` für Tailwind v4 Syntax-Support
- [x] `excludeCSS()` Helper: JS-Regeln von CSS-Dateien fernhalten (language-Inkompatibilität)
- [x] CSS-Regeln: `no-empty-blocks`, `no-duplicate-imports`, `no-invalid-at-rules`
@ -261,14 +422,20 @@ OsButton Features:
1. ~~Phase 0: Komponenten-Analyse~~
2. ~~Phase 1: Vue 2.7 Upgrade~~
3. ~~**Phase 2: Projekt-Setup**~~ ✅ ABGESCHLOSSEN
4. **Phase 3: Webapp-Integration** - 32/90 Buttons migriert (36%)
4. ~~**Phase 3: Webapp-Integration**~~ ✅ ABGESCHLOSSEN — 133 Buttons in 79 Dateien
- [x] yarn link / Webpack-Alias in Webapp
- [x] CSS-Variablen definieren (ocelot-ui-variables.scss)
- [x] 16 Buttons migriert & validiert ✅
- [x] Docker Build + CI-Kompatibilität
- [x] **Milestone 4a:** 14 weitere Buttons (ohne neue Props) ✅
- [ ] **Milestone 4b:** icon/circle/loading Props implementieren
- [ ] **Milestone 4c:** ~60 Buttons mit icon/circle/loading migrieren
- [x] **Milestone 4b:** icon/circle/loading Props implementieren ✅
- [x] **Milestone 4c:** Alle verbleibenden Buttons migriert ✅
- [x] **Code-Review Feedback:** Refactoring, A11y, Vue 3 Compat, CSS-Scoping ✅
5. **Nächstes:**
- [ ] GroupButton + MapButton in HeaderMenu inlinen (keine eigene Komponente nötig)
- [ ] `compat/` Verzeichnis in packages/ui anlegen (temporäre Migrations-Wrapper)
- [ ] BaseIcon nach `compat/` verschieben (131 Nutzungen, Voraussetzung für weitere Migrationen)
- [ ] Snapshots/Tests aktualisieren, BaseButton-Komponente ggf. entfernen
**Manuelle Setup-Aufgaben (außerhalb Code):**
- [ ] `NPM_TOKEN` als GitHub Secret einrichten (für npm publish in ui-release.yml)
@ -361,106 +528,120 @@ OsButton Features:
- [x] OsButton attrs/listeners Forwarding (Vue 2 $listeners via getCurrentInstance)
- [x] 14 weitere Buttons migriert (alle ohne icon/circle/loading)
**Milestone 4a: Weitere Buttons migrieren (14 ohne neue Props)**
- [ ] Modal Cancel-Buttons (DisableModal, DeleteUserModal, ReleaseModal)
- [ ] Form Cancel/Submit-Buttons (ContributionForm, EnterNonce, MySomethingList)
- [ ] ImageUploader.vue (2× Crop-Buttons)
- [ ] Page-Buttons (donations, badges, notifications/index, profile Unblock/Unmute)
- [ ] ReportRow.vue More-Details-Button
**Milestone 4a: Weitere Buttons migrieren (14 ohne neue Props)**
- [x] Modal Cancel-Buttons (DisableModal, DeleteUserModal, ReleaseModal)
- [x] Form Cancel/Submit-Buttons (ContributionForm, EnterNonce, MySomethingList)
- [x] ImageUploader.vue (2× Crop-Buttons)
- [x] Page-Buttons (donations, badges, notifications/index, profile Unblock/Unmute)
- [x] ReportRow.vue More-Details-Button
**Milestone 4b: OsButton Props erweitern**
- [ ] `icon` Prop implementieren (slot-basiert oder Icon-Komponente)
- [ ] `circle` Variant zu CVA hinzufügen
- [ ] `loading` Prop mit Spinner implementieren
**Milestone 4b: OsButton Props erweitern**
- [x] `icon` Slot implementiert (slot-basiert, icon-system-agnostisch) ✅
- [x] `circle` Prop implementiert (rounded-full, größenabhängige Breiten) ✅
- [x] `loading` Prop mit animiertem SVG-Spinner implementiert ✅
**Milestone 4c: Buttons mit icon/circle/loading migrieren (~60 Buttons)**
**Milestone 4c: Buttons mit icon/circle/loading migrieren** ✅ ABGESCHLOSSEN
*Button-Komponenten (Wrapper):*
- [ ] Button/JoinLeaveButton.vue (icon, loading)
- [ ] Button/FollowButton.vue (icon, loading)
- [ ] LoginButton/LoginButton.vue (icon, circle)
- [ ] InviteButton/InviteButton.vue (icon, circle)
- [ ] EmotionButton/EmotionButton.vue (circle)
- [ ] CustomButton/CustomButton.vue (2× circle)
- [ ] LabeledButton/LabeledButton.vue (icon, circle)
- [x] Button/JoinLeaveButton.vue (icon, loading) ✅
- [x] Button/FollowButton.vue (icon, loading) ✅
- [x] LoginButton/LoginButton.vue (icon, circle) ✅
- [x] InviteButton/InviteButton.vue (icon, circle) ✅
- [x] EmotionButton/EmotionButton.vue (circle) ✅
- [x] CustomButton/CustomButton.vue (2× circle) ✅
- [x] LabeledButton/LabeledButton.vue (icon, circle) ✅
*Navigation & Menus:*
- [ ] ContentMenu/ContentMenu.vue (icon, circle)
- [ ] ContentMenu/GroupContentMenu.vue (icon, circle)
- [ ] ChatNotificationMenu.vue (circle)
- [ ] NotificationMenu.vue (3× icon, circle)
- [ ] HeaderMenu/HeaderMenu.vue (icon, circle)
- [ ] Map/MapButton.vue (circle)
- [x] ContentMenu/ContentMenu.vue (icon, circle) ✅
- [x] ContentMenu/GroupContentMenu.vue (icon, circle) ✅
- [x] ChatNotificationMenu.vue (circle) ✅
- [x] NotificationMenu.vue (3× icon, circle) ✅
- [x] HeaderMenu/HeaderMenu.vue (icon, circle) ✅
- [x] Map/MapButton.vue (circle) ✅
*Editor:*
- [ ] Editor/MenuBarButton.vue (icon, circle)
- [ ] Editor/MenuLegend.vue (~10× icon, circle)
- [x] Editor/MenuBar.vue (~11× icon, circle) ✅
- [x] Editor/MenuLegend.vue (2× icon) ✅
*Filter & Input:*
- [ ] HashtagsFilter.vue (icon, circle)
- [ ] CategoriesSelect.vue (icon)
- [ ] SearchableInput.vue (icon, circle)
- [ ] Select/LocationSelect.vue (icon)
- [ ] PaginationButtons.vue (2× icon, circle)
- [x] HashtagsFilter.vue (icon, circle) ✅
- [x] CategoriesSelect.vue (icon) ✅
- [x] SearchableInput.vue (icon, circle) ✅
- [x] Select/LocationSelect.vue (icon) ✅
- [x] PaginationButtons.vue (2× icon, circle) ✅
*Chat:*
- [ ] Chat/Chat.vue (2× icon, circle)
- [ ] Chat/AddChatRoomByUserSearch.vue (icon, circle)
- [x] Chat/Chat.vue (2× icon, circle) ✅
- [x] Chat/AddChatRoomByUserSearch.vue (icon, circle) ✅
*Forms & Auth:*
- [ ] LoginForm/LoginForm.vue (icon, loading)
- [ ] PasswordReset/Request.vue (loading)
- [ ] PasswordReset/ChangePassword.vue (loading)
- [ ] Password/Change.vue (loading)
- [ ] ContributionForm.vue Submit (icon, loading)
- [ ] GroupForm.vue Submit (icon)
- [ ] CommentForm/CommentForm.vue (loading)
- [x] LoginForm/LoginForm.vue (icon, loading) ✅
- [x] PasswordReset/Request.vue (loading) ✅
- [x] PasswordReset/ChangePassword.vue (loading) ✅
- [x] Password/Change.vue (loading) ✅
- [x] ContributionForm.vue Submit (icon, loading) ✅
- [x] CommentForm/CommentForm.vue (loading) ✅
*Modals:*
- [ ] Modal/ConfirmModal.vue (2× icon, loading)
- [ ] Modal/ReportModal.vue (2× icon, loading)
- [ ] Modal/DisableModal.vue Confirm (icon)
- [ ] Modal/DeleteUserModal.vue Confirm (icon)
- [ ] Modal/ReleaseModal.vue Confirm (icon)
- [x] Modal/ConfirmModal.vue (2× icon, loading) ✅
- [x] Modal/ReportModal.vue (2× icon, loading) ✅
- [x] Modal/DisableModal.vue Confirm (icon) ✅
- [x] Modal/DeleteUserModal.vue Confirm (icon) ✅
- [x] Modal/ReleaseModal.vue Confirm (icon) ✅
*Features:*
- [ ] ComponentSlider.vue (2× icon, loading)
- [ ] MySomethingList.vue (3× icon, circle, loading)
- [ ] CreateInvitation.vue (icon, circle)
- [ ] Invitation.vue (2× icon, circle)
- [ ] ProfileList.vue (loading)
- [ ] ReportRow.vue Confirm (icon)
- [ ] ImageUploader.vue Delete/Cancel (2× icon, circle)
- [ ] CommentCard.vue Reply (icon, circle)
- [ ] EmbedComponent.vue Close (icon, circle)
- [ ] CtaUnblockAuthor.vue (icon)
- [ ] data-download.vue (icon, loading)
- [x] ComponentSlider.vue (2× icon) ✅
- [x] MySomethingList.vue (3× icon, circle) ✅
- [x] CreateInvitation.vue (icon, circle) ✅
- [x] Invitation.vue (2× icon, circle) ✅
- [x] ProfileList.vue (loading) ✅
- [x] ReportRow.vue Confirm (icon) ✅
- [x] ImageUploader.vue Delete/Cancel (2× icon, circle) ✅
- [x] CommentCard.vue Reply (icon, circle) ✅
- [x] EmbedComponent.vue Close (icon, circle) ✅
- [x] CtaUnblockAuthor.vue (icon) ✅
- [x] data-download.vue (icon, loading) ✅
- [x] ActionButton.vue (icon, circle) ✅
- [x] DeleteData.vue (icon) ✅
- [x] GroupButton.vue (icon, circle) ✅
*Filter-Menüs:*
- [x] FilterMenu/FilterMenu.vue (icon) ✅
- [x] FilterMenu/HeaderButton.vue (2× icon) ✅
- [x] FilterMenu/CategoriesFilter.vue (2× icon) ✅
- [x] FilterMenu/OrderByFilter.vue (2×) ✅
- [x] FilterMenu/EventsByFilter.vue (2×) ✅
- [x] FilterMenu/FollowingFilter.vue (3×) ✅
*Pages:*
- [ ] pages/groups/_id/_slug.vue (3× icon, circle, loading)
- [ ] pages/admin/users/index.vue (2× icon, circle, loading)
- [ ] pages/settings/index.vue (icon, loading)
- [ ] pages/settings/blocked-users.vue (icon, circle)
- [ ] pages/settings/muted-users.vue (icon, circle)
- [ ] pages/settings/my-email-address/*.vue (2× icon)
- [ ] pages/profile/_id/_slug.vue Chat (icon)
- [ ] pages/post/_id/_slug/index.vue (icon, circle)
- [x] pages/index.vue (2× icon, circle) ✅
- [x] pages/groups/index.vue (icon, circle) ✅
- [x] pages/groups/_id/_slug.vue (3× icon, circle) ✅
- [x] pages/admin/users/index.vue (2× icon, circle) ✅
- [x] pages/settings/index.vue (icon) ✅
- [x] pages/settings/blocked-users.vue (icon, circle) ✅
- [x] pages/settings/muted-users.vue (icon, circle) ✅
- [x] pages/settings/data-download.vue (icon) ✅
- [x] pages/settings/my-email-address/index.vue (icon) ✅
- [x] pages/settings/my-email-address/enter-nonce.vue (icon) ✅
- [x] pages/profile/_id/_slug.vue (icon, circle) ✅
- [x] pages/post/_id/_slug/index.vue (icon, circle) ✅
**Milestone 5: Validierung & Dokumentation** ✅
- [x] Keine visuellen Änderungen bestätigt (16/16 Buttons validiert)
- [x] Keine funktionalen Änderungen bestätigt
- [x] Disabled-Styles korrigiert (hover/active-Override, Border-Fix)
- [ ] Webapp-Tests bestehen weiterhin (TODO: Regressionstest)
- [ ] Webapp-Tests bestehen weiterhin (TODO: Snapshots aktualisieren)
- [ ] Erkenntnisse in KATALOG.md dokumentiert
**Einsatzstellen-Übersicht:**
| Kategorie | Buttons | Status |
|-----------|---------|--------|
| ✅ Migriert & Validiert | 24 | Erledigt |
| ⏳ Ohne neue Props (M4a) | 6 | In Arbeit (8 von 14 erledigt) |
| ⬜ Mit icon/circle/loading (M4c) | ~60 | Ausstehend |
| **Gesamt** | **~90** | **27% erledigt** |
| ✅ Migriert (gesamt) | 133 | 79 Dateien |
| `<base-button>` verbleibend | 0 | Nur BaseButton.vue Definition + Test-Dateien |
| ⬜ `<ds-button>` verbleibend | 0 | Alle ersetzt |
| **Gesamt** | **133** | **100% erledigt** |
**Details siehe KATALOG.md** (vollständige Tracking-Tabellen)
@ -1486,6 +1667,87 @@ Bei der Migration werden:
| 2026-02-10 | **CI-Workflow-Trigger** | 9 UI-Workflows von `on: push` auf `push`+`pull_request` mit Branch-Filter (`master`) und Path-Filter (`packages/ui/**` + Workflow-Datei) umgestellt |
| 2026-02-10 | **custom-class entfernt** | `custom-class` Prop (entfernt aus OsButton) → `class` Attribut in notifications.vue, MapStylesButtons.vue, EmbedComponent.vue (4 Stellen); Snapshot aktualisiert |
| 2026-02-10 | **Vue 3 Template-Fix** | `this.$t()``$t()` in CommentCard.vue (this im Template in Vue 3 nicht verfügbar) |
| 2026-02-11 | **Icon-Slot implementiert** | Benannter `#icon` Slot für OsButton, slot-basiert statt Icon-Prop (icon-system-agnostisch) |
| 2026-02-11 | **Icon-Wrapper Klassen** | Tailwind-Utility-Klassen direkt auf `<span>`: `inline-flex items-center shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current` |
| 2026-02-11 | **VNode Text-Erkennung** | `hasText` prüft VNode-Children auf sichtbaren Inhalt; whitespace-only → icon-only Verhalten |
| 2026-02-11 | **Gap & Margin Logik** | `gap-2` bei Icon+Text, `-ml-1` bei Icon, `-ml-1 -mr-1` bei Icon-Only (optischer Ausgleich) |
| 2026-02-11 | **4 neue Stories** | Icon, IconOnly, IconSizes, IconAppearances mit Inline-SVG Komponenten (CheckIcon, CloseIcon, PlusIcon) |
| 2026-02-11 | **Playground erweitert** | Reaktiver Icon-Selektor (none/check/close/plus) + Label-Text-Control via `computed()` |
| 2026-02-11 | **Storybook: components Option** | Funktionale Komponenten müssen in `components` registriert werden, nicht in `setup()` return |
| 2026-02-11 | **Storybook: CSS nicht in index.css** | Storybook lädt eigene `storybook.css`, nicht `src/styles/index.css` → Utility-Klassen direkt verwenden |
| 2026-02-11 | **SVG-Targeting** | `[&>svg]` statt `[&>*]` für Icon-Sizing (BaseIcon rendert `<span><svg>`, Wrapper-Span darf nicht beeinflusst werden) |
| 2026-02-11 | **my-email-address migriert** | Save-Button: `<os-button variant="primary">` mit `<template #icon><base-icon name="check" /></template>` |
| 2026-02-11 | **Code-Optimierung** | `ICON_CLASS` Konstante extrahiert, `iconMargin` Variable, vereinfachte `hasText`-Logik (kein Symbol.for) |
| 2026-02-11 | **Größenabhängiger Gap** | `gap-1` (4px) für xs/sm, `gap-2` (8px) für md/lg/xl bei Icon+Text |
| 2026-02-11 | **Größenabhängiger Margin** | Kein negativer Icon-Margin bei xs/sm (voller Padding-Abstand zur Button-Grenze) |
| 2026-02-11 | **DisableModal.vue** | Confirm-Button migriert: `danger filled icon="exclamation-circle"``variant="danger"` + `#icon` Slot |
| 2026-02-11 | **DeleteUserModal.vue** | Confirm-Button migriert: identisches Pattern wie DisableModal |
| 2026-02-11 | **CtaUnblockAuthor.vue** | Button migriert: `filled icon="arrow-right"``variant="primary"` + `#icon` Slot, OsButton importiert |
| 2026-02-11 | **LocationSelect.vue** | Icon-only Close-Button migriert: `ghost size="small" icon="close"``variant="primary" appearance="ghost" size="sm"` + aria-label |
| 2026-02-11 | **CategoriesSelect.vue** | v-for Buttons migriert: dynamisches `:icon``#icon` Slot, `:filled``:appearance`, CSS `.base-button``button` |
| 2026-02-11 | **profile/_id/_slug.vue** | Chat-Button migriert: `icon="chat-bubble"``variant="primary" appearance="outline" full-width` + `#icon` Slot |
| 2026-02-11 | **verify.vue korrigiert** | Kein Button vorhanden (Eintrag aus Milestone-Liste entfernt) |
| 2026-02-11 | **PaginationButtons.vue** | 2 circle icon-only Buttons migriert: `outline primary circle` + `#icon` Slot + aria-label |
| 2026-02-11 | **OsButton: circle Prop** | `circle` Prop: `rounded-full p-0` + größenabhängige Breiten (CIRCLE_WIDTHS Map) |
| 2026-02-11 | **OsButton: loading Prop** | Animierter SVG-Spinner mit `aria-busy="true"`, Button auto-disabled bei loading |
| 2026-02-11 | **Spinner-Architektur** | Beide Animationen (rotate + dash) auf `<circle>` Element; SVG ist statischer Container; Chrome-Compositing-Bug-Workaround |
| 2026-02-11 | **Spinner-Zentrierung** | Icon-Buttons: Spinner über Icon (translate-basiert, overflow:visible); Text-Buttons: Spinner im Button-Container (inset-0 m-auto) |
| 2026-02-11 | **animations.css** | Keyframes `os-spinner-dash` + `os-spinner-rotate` in separate CSS-Datei ausgelagert |
| 2026-02-11 | **min-width pro Größe** | `min-w-[26px]`/`min-w-[36px]`/`min-w-12`/`min-w-14` in button.variants.ts (verhindert zu kleine leere Buttons) |
| 2026-02-11 | **Code-Optimierung** | OsButton ~250→207 Zeilen: buttonData geteilt, SPINNER_PX vereinfacht, redundante cn() entfernt, getCurrentInstance nur Vue 2 |
| 2026-02-11 | **5 neue Unit-Tests** | default type, data-appearance, min-w, icon-only loading, circle gap-1; gesamt: 76 Tests |
| 2026-02-11 | **Milestone 4b abgeschlossen** | icon ✅, circle ✅, loading ✅ — alle OsButton-Props implementiert |
| 2026-02-11 | **Milestone 4c: 59 Buttons** | Chat (2), AddChatRoomByUserSearch (1), CommentCard (1), CommentForm (2), ComponentSlider (2), ContributionForm (1), DeleteData (1), EmbedComponent (1), FilterMenu (1), HeaderButton (2), CategoriesFilter (2), OrderByFilter (2), EventsByFilter (2), FollowingFilter (3), GroupButton (1), ConfirmModal (2), ReportModal (2), Password/Change (1), PasswordReset/Request (1), PasswordReset/ChangePassword (1), Registration/Signup (1), ReleaseModal (1), ImageUploader (2), CreateInvitation (1), Invitation (2), ProfileList (1), ReportRow (1), MySomethingList (3), ActionButton (1), pages/index (2), profile/add-post (1), post/blur-toggle (1), groups/slug (3), settings/index (1), admin/users (2), blocked-users (1), data-download (1), muted-users (1), groups/index (1), enter-nonce (1) |
| 2026-02-11 | **type="submit" Pattern** | OsButton hat `type="button"` als Default; alle Form-Submit-Buttons brauchen explizit `type="submit"` |
| 2026-02-11 | **!!errors Pattern** | DsForm `errors` ist ein Objekt, nicht Boolean; OsButton `disabled` Prop erwartet Boolean → `!!errors` nötig |
| 2026-02-11 | **CSS-Selector Pattern** | `.base-button``> button` oder `button`; Position/Dimensions brauchen `!important` für Tailwind-Override |
| 2026-02-11 | **Disabled border-color** | Outline disabled border von `var(--color-disabled)` auf `var(--color-disabled-border,#e5e3e8)` mit Fallback |
| 2026-02-11 | **Phase 3 abgeschlossen** | 132 `<os-button>` Tags in 78 Dateien, 0 `<base-button>` in Templates verbleibend |
| 2026-02-11 | **Password/Change.vue Fix** | `!!errors` für disabled-Prop (DsForm errors ist Objekt) |
| 2026-02-11 | **CommentForm.vue Fix** | `type="submit"` fehlte + `!!errors` für disabled-Prop |
| 2026-02-11 | **GroupForm.vue ds-button** | Letzter `<ds-button>` in Webapp → `<os-button>` mit `#icon` Slot migriert |
| 2026-02-11 | **OsButton.spec.ts TS-Fix** | `size` aus `Object.entries` als Union Type gecastet (`as 'sm' | 'md' | 'lg' | 'xl'`) |
| 2026-02-11 | **Coverage 100%** | `v8 ignore start/stop` für Vue 2 Branch, `v8 ignore next` für defensive `||` Fallback |
| 2026-02-11 | **Scope: 133 Buttons** | 133 `<os-button>` Tags in 79 Dateien, 0 `<base-button>` + 0 `<ds-button>` verbleibend |
| 2026-02-12 | **data-variant Attribut** | OsButton rendert `data-variant` auf `<button>` (konsistent mit `data-appearance`), ermöglicht CSS-Selektoren wie `button[data-variant="danger"]` |
| 2026-02-12 | **notifications.spec.js** | Test-API korrigiert: `wrapper.find()` (Vue Test Utils) → `screen.getByText()` (Testing Library), `button.disabled` statt `button.attributes('disabled')` |
| 2026-02-12 | **FilterMenu Regressionsbug** | `appearance="ghost"` war hardcoded statt dynamisch; `filterActive` Computed Property existierte aber war nicht genutzt → `:appearance="filterActive ? 'filled' : 'ghost'"` |
| 2026-02-12 | **FilterMenu.spec.js** | Test von CSS-Klasse `--filled` auf `data-appearance="filled"` Attribut-Selektor umgestellt |
| 2026-02-12 | **CtaUnblockAuthor.vue** | Typo `require: true``required: true` (Vue ignorierte die Prop-Validierung) |
| 2026-02-12 | **LocationSelect.vue Fixes** | `event.target.value``this.currentValue` (Button hat kein value), `@click.native``@click` (Vue 3), `aria-label` via i18n |
| 2026-02-12 | **i18n: actions.clear** | Neuer Key in allen 9 Sprachdateien: en=Clear, de=Zurücksetzen, fr=Effacer, es=Borrar, it=Cancella, nl=Wissen, pl=Wyczyść, pt=Limpar, ru=Очистить |
| 2026-02-12 | **OsButton JSDoc** | Slot-Dokumentation (`@slot default`, `@slot icon`) für vue-component-meta/Storybook autodocs |
| 2026-02-12 | **OsButton xs entfernt** | `isSmall` von `['xs', 'sm'].includes(size)` auf `size === 'sm'` vereinfacht (xs ist kein gültiger Size-Wert) |
| 2026-02-12 | **Strikte Typisierung** | `type Size = NonNullable<ButtonVariants['size']>`, `Record<Size, ...>` für CIRCLE_WIDTHS + SPINNER_PX; `props.size!``(props.size ?? 'md') as Size` |
| 2026-02-12 | **animations.css** | Stylelint-konforme Formatierung: eine Deklaration pro Zeile, Leerzeilen zwischen Keyframe-Stufen |
| 2026-02-12 | **OsButton Refactoring** | `vueAttrs()` Helper für Vue 2/3 Attribut-Handling, Einmal-Variablen durch `cn()` ersetzt, `children` inline; 77 Tests, 100% Coverage |
| 2026-02-12 | **CSS @import Reihenfolge** | `@import "./animations.css"` vor `@source`-Direktiven verschoben (CSS-Spec: @import vor anderen At-Rules) |
| 2026-02-12 | **CustomButton Cleanup** | `isEmpty` aus `data()` entfernt — reine Utility-Funktion braucht keine Vue-Reaktivität |
| 2026-02-12 | **notifications.spec.js** | Doppelten `beforeEach` konsolidiert; `wrapper` von Modulebene in `describe`-Block verschoben |
| 2026-02-12 | **Style-Scoping** | MenuLegend.vue: `<style scoped>` hinzugefügt; ReportModal + DeleteUserModal: CSS-Selektoren mit Komponenten-Prefix |
| 2026-02-12 | **data-test Selektoren** | LocationSelect (`clear-location-button`) + HashtagsFilter (`clear-search-button`): spezifischere Test-Selektoren |
| 2026-02-12 | **Vue 3 Compat Fixes** | FollowButton: `.native` entfernt; FilterMenu: `slot`/`slot-scope``<template #default>`; HashtagsFilter: `this.$t()``$t()` |
| 2026-02-12 | **A11y: aria-label** | GroupContentMenu icon-only Button: `$t('group.contentMenu.menuButton')`; PaginationButtons: `$t('pagination.previous/next')` |
| 2026-02-12 | **i18n Keys** | `pagination.previous/next` + `group.contentMenu.menuButton` in allen 9 Sprachdateien angelegt |
| 2026-02-12 | **Modal Konsistenz** | DisableModal + DeleteUserModal: `appearance="filled"` + `:loading="loading"` auf Danger-Buttons |
| 2026-02-12 | **Loading State** | my-email-address/index.vue: `loadingData` hinzugefügt + `finally` Block für Reset |
| 2026-02-12 | **MapButton #icon Slot** | Icon von Default-Slot in `<template #icon>` verschoben (konsistent mit allen anderen Buttons) |
| 2026-02-12 | **Dead Code entfernt** | MySomethingList.vue: `.icon-button` CSS-Klasse (nach Migration nicht mehr verwendet) |
| 2026-02-12 | **Button-Wrapper-Analyse** | 15 OsButton-Wrapper klassifiziert: 4 Smart (Apollo/Vuex), 4 Presentational, 7 Borderline; GroupButton + MapButton als Inline-Kandidaten identifiziert |
| 2026-02-12 | **compat/ Konzept** | Separates Verzeichnis für temporäre Migrations-Wrapper (nicht von check-completeness.ts erfasst); BaseIcon als erster Kandidat (131 Nutzungen) |
| 2026-02-13 | **data-test Selektoren** | ~10 Komponenten: `data-test` Attribute für robuste Test-Selektoren (unmute-btn, unblock-btn, follow-btn, join-leave-btn, login-btn, load-all-connections-btn, content-menu-button) |
| 2026-02-13 | **Cypress Selektoren** | 4 Step-Definitions: `.user-content-menu button` / `.content-menu button``[data-test="content-menu-button"]` |
| 2026-02-13 | **Spec-Selektoren** | FollowList, FollowButton, LoginButton, CtaJoinLeaveGroup, ReportRow, muted-users, blocked-users: generische `button``[data-test="..."]` |
| 2026-02-13 | **A11y: aria-label** | ~15 icon-only Buttons: aria-label hinzugefügt (admin/users, AddChatRoom, EmbedComponent, groups, profile, CustomButton, HeaderMenu, ImageUploader, ContentMenu, HeaderButton, InviteButton, LoginButton, blocked/muted-users) |
| 2026-02-13 | **Zustandsabhängiges aria-label** | post/_id/_slug: `$t(blurred ? 'post.sensitiveContent.show' : 'post.sensitiveContent.hide')` |
| 2026-02-13 | **ComponentSlider aria-label** | Interpoliertes Label: `$t('component-slider.step', { current: index + 1, total: ... })` |
| 2026-02-13 | **i18n Keys (6 neue)** | `actions.search`, `actions.close`, `actions.menu`, `site.navigation`, `post.sensitiveContent.show/hide`, `component-slider.step` in allen 9 Sprachdateien |
| 2026-02-13 | **Loading-State Fixes** | DisableModal + ReleaseModal: `finally { this.loading = false }` für Reset |
| 2026-02-13 | **Bugfixes** | ChangePassword: `!!errors`; Password/Change: `disabled` aus data() entfernt + 2 tote Tests; MenuBar: unbenutztes `ref` entfernt |
| 2026-02-13 | **Button-Props** | GroupForm cancel: `variant="default" appearance="filled"`; donations/LoginForm/EnterNonce: `appearance="filled"` ergänzt |
| 2026-02-13 | **CSS-Selektoren** | LoginForm: `.login-form button``.login-form button[type='submit']`; pages/index: redundante Klasse auf BaseIcon entfernt |
| 2026-02-13 | **JoinLeaveButton** | `.native` von `@mouseenter`/`@mouseleave` entfernt (Vue 3 Kompatibilität) |
| 2026-02-13 | **MySomethingList** | `:title` + `:aria-label` auf Edit/Delete-Buttons (Tooltip beibehalten neben Accessibility) |
---

View File

@ -78,6 +78,19 @@ describe('osButton', () => {
})
expect(wrapper.classes()).toContain('h-12')
})
it('has min-width matching height for each size', () => {
const sizes = {
sm: 'min-w-[26px]',
md: 'min-w-[36px]',
lg: 'min-w-12',
xl: 'min-w-14',
} as const
for (const [size, expected] of Object.entries(sizes)) {
const wrapper = mount(OsButton, { props: { size: size as keyof typeof sizes } })
expect(wrapper.classes()).toContain(expected)
}
})
})
it('applies fullWidth class', () => {
@ -101,6 +114,11 @@ describe('osButton', () => {
expect(wrapper.attributes('disabled')).toBeDefined()
})
it('defaults to type="button"', () => {
const wrapper = mount(OsButton)
expect(wrapper.attributes('type')).toBe('button')
})
it('sets button type', () => {
const wrapper = mount(OsButton, {
props: { type: 'submit' },
@ -108,6 +126,20 @@ describe('osButton', () => {
expect(wrapper.attributes('type')).toBe('submit')
})
it('sets data-variant attribute', () => {
const wrapper = mount(OsButton, {
props: { variant: 'danger' },
})
expect(wrapper.attributes('data-variant')).toBe('danger')
})
it('sets data-appearance attribute', () => {
const wrapper = mount(OsButton, {
props: { appearance: 'outline' },
})
expect(wrapper.attributes('data-appearance')).toBe('outline')
})
it('emits click event', async () => {
const wrapper = mount(OsButton)
await wrapper.trigger('click')
@ -131,6 +163,337 @@ describe('osButton', () => {
})
})
describe('icon slot', () => {
it('renders icon slot content in .os-button__icon wrapper', () => {
const wrapper = mount(OsButton, {
slots: { icon: '<svg data-testid="icon"></svg>' },
})
const iconWrapper = wrapper.find('.os-button__icon')
expect(iconWrapper.exists()).toBeTruthy()
expect(iconWrapper.find('[data-testid="icon"]').exists()).toBeTruthy()
})
it('renders both icon and text', () => {
const wrapper = mount(OsButton, {
slots: {
icon: '<svg data-testid="icon"></svg>',
default: 'Save',
},
})
expect(wrapper.find('.os-button__icon').exists()).toBeTruthy()
expect(wrapper.text()).toContain('Save')
})
it('adds gap-2 class when icon and text are present', () => {
const wrapper = mount(OsButton, {
slots: {
icon: '<svg></svg>',
default: 'Save',
},
})
const contentSpan = wrapper.find('button > span')
expect(contentSpan.classes()).toContain('gap-2')
})
it('adds gap-1 class for small sizes with icon and text', () => {
const wrapper = mount(OsButton, {
props: { size: 'sm' },
slots: {
icon: '<svg></svg>',
default: 'Save',
},
})
const contentSpan = wrapper.find('button > span')
expect(contentSpan.classes()).toContain('gap-1')
expect(contentSpan.classes()).not.toContain('gap-2')
})
it('does not add gap-2 for icon-only button', () => {
const wrapper = mount(OsButton, {
slots: { icon: '<svg></svg>' },
})
const contentSpan = wrapper.find('button > span')
expect(contentSpan.classes()).not.toContain('gap-2')
})
it('treats whitespace-only text as icon-only', () => {
const wrapper = mount(OsButton, {
slots: {
icon: '<svg></svg>',
default: ' ',
},
})
const contentSpan = wrapper.find('button > span')
expect(contentSpan.classes()).not.toContain('gap-2')
expect(wrapper.find('.os-button__icon').classes()).toContain('-mr-1')
})
it('does not add gap-2 without icon', () => {
const wrapper = mount(OsButton, {
slots: { default: 'Click me' },
})
const contentSpan = wrapper.find('button > span')
expect(contentSpan.classes()).not.toContain('gap-2')
})
it('renders without icon slot (backward compat)', () => {
const wrapper = mount(OsButton, {
slots: { default: 'Click me' },
})
expect(wrapper.find('.os-button__icon').exists()).toBeFalsy()
expect(wrapper.text()).toBe('Click me')
})
it('renders icon-only button without text', () => {
const wrapper = mount(OsButton, {
slots: { icon: '<svg></svg>' },
})
expect(wrapper.find('.os-button__icon').exists()).toBeTruthy()
expect(wrapper.text()).toBe('')
})
})
describe('circle prop', () => {
it('renders as round button with rounded-full and p-0', () => {
const wrapper = mount(OsButton, {
props: { circle: true },
slots: { icon: '<svg></svg>' },
attrs: { 'aria-label': 'Add' },
})
expect(wrapper.classes()).toContain('rounded-full')
expect(wrapper.classes()).toContain('p-0')
})
it('applies w-[36px] width for md size (default)', () => {
const wrapper = mount(OsButton, {
props: { circle: true },
slots: { icon: '<svg></svg>' },
attrs: { 'aria-label': 'Add' },
})
expect(wrapper.classes()).toContain('w-[36px]')
})
it('applies w-[26px] width for sm size', () => {
const wrapper = mount(OsButton, {
props: { circle: true, size: 'sm' },
slots: { icon: '<svg></svg>' },
attrs: { 'aria-label': 'Add' },
})
expect(wrapper.classes()).toContain('w-[26px]')
})
it('applies w-12 width for lg size', () => {
const wrapper = mount(OsButton, {
props: { circle: true, size: 'lg' },
slots: { icon: '<svg></svg>' },
attrs: { 'aria-label': 'Add' },
})
expect(wrapper.classes()).toContain('w-12')
})
it('applies w-14 width for xl size', () => {
const wrapper = mount(OsButton, {
props: { circle: true, size: 'xl' },
slots: { icon: '<svg></svg>' },
attrs: { 'aria-label': 'Add' },
})
expect(wrapper.classes()).toContain('w-14')
})
it('is combinable with primary variant', () => {
const wrapper = mount(OsButton, {
props: { circle: true, variant: 'primary' },
slots: { icon: '<svg></svg>' },
attrs: { 'aria-label': 'Add' },
})
expect(wrapper.classes()).toContain('rounded-full')
expect(wrapper.classes()).toContain('bg-[var(--color-primary)]')
})
it('is combinable with ghost appearance', () => {
const wrapper = mount(OsButton, {
props: { circle: true, appearance: 'ghost', variant: 'primary' },
slots: { icon: '<svg></svg>' },
attrs: { 'aria-label': 'Add' },
})
expect(wrapper.classes()).toContain('rounded-full')
expect(wrapper.classes()).toContain('bg-transparent')
})
it('icon has no negative margin in circle mode', () => {
const wrapper = mount(OsButton, {
props: { circle: true },
slots: { icon: '<svg></svg>' },
attrs: { 'aria-label': 'Add' },
})
const iconWrapper = wrapper.find('.os-button__icon')
expect(iconWrapper.classes()).not.toContain('-ml-1')
expect(iconWrapper.classes()).not.toContain('-mr-1')
})
it('uses gap-1 for circle with icon and text', () => {
const wrapper = mount(OsButton, {
props: { circle: true },
slots: {
icon: '<svg></svg>',
default: 'Add',
},
})
const contentSpan = wrapper.find('button > span')
expect(contentSpan.classes()).toContain('gap-1')
expect(contentSpan.classes()).not.toContain('gap-2')
})
it('does not apply circle classes when circle is false', () => {
const wrapper = mount(OsButton, {
props: { circle: false },
slots: { default: 'Click me' },
})
expect(wrapper.classes()).not.toContain('rounded-full')
expect(wrapper.classes()).not.toContain('p-0')
})
})
describe('loading prop', () => {
it('renders spinner SVG when loading=true', () => {
const wrapper = mount(OsButton, {
props: { loading: true },
slots: { default: 'Save' },
})
expect(wrapper.find('.os-button__spinner').exists()).toBeTruthy()
expect(wrapper.find('svg').exists()).toBeTruthy()
})
it('disables button when loading=true', () => {
const wrapper = mount(OsButton, {
props: { loading: true },
slots: { default: 'Save' },
})
expect(wrapper.attributes('disabled')).toBeDefined()
})
it('sets aria-busy="true" when loading', () => {
const wrapper = mount(OsButton, {
props: { loading: true },
slots: { default: 'Save' },
})
expect(wrapper.attributes('aria-busy')).toBe('true')
})
it('keeps content visible when loading', () => {
const wrapper = mount(OsButton, {
props: { loading: true },
slots: { default: 'Save' },
})
const contentSpan = wrapper.find('span')
expect(contentSpan.classes()).not.toContain('opacity-0')
expect(wrapper.text()).toContain('Save')
})
it('does not render spinner when loading=false (default)', () => {
const wrapper = mount(OsButton, {
slots: { default: 'Save' },
})
expect(wrapper.find('.os-button__spinner').exists()).toBeFalsy()
})
it('does not set aria-busy when not loading', () => {
const wrapper = mount(OsButton, {
slots: { default: 'Save' },
})
expect(wrapper.attributes('aria-busy')).toBeUndefined()
})
it('loading + disabled: button remains disabled', () => {
const wrapper = mount(OsButton, {
props: { loading: true, disabled: true },
slots: { default: 'Save' },
})
expect(wrapper.attributes('disabled')).toBeDefined()
})
it('does not emit click when loading', async () => {
const wrapper = mount(OsButton, {
props: { loading: true },
slots: { default: 'Save' },
})
await wrapper.trigger('click')
expect(wrapper.emitted('click')).toBeUndefined()
})
it('renders spinner inside icon wrapper when icon is present', () => {
const wrapper = mount(OsButton, {
props: { loading: true },
slots: {
icon: '<svg data-testid="icon"></svg>',
default: 'Save',
},
})
const iconWrapper = wrapper.find('.os-button__icon')
expect(iconWrapper.exists()).toBeTruthy()
expect(iconWrapper.find('.os-button__spinner').exists()).toBeTruthy()
})
it('keeps icon visible when loading with icon', () => {
const wrapper = mount(OsButton, {
props: { loading: true },
slots: {
icon: '<svg data-testid="icon"></svg>',
default: 'Save',
},
})
const iconWrapper = wrapper.find('.os-button__icon')
expect(iconWrapper.classes()).not.toContain('[&>*]:invisible')
})
it('renders spinner as direct button child when no icon', () => {
const wrapper = mount(OsButton, {
props: { loading: true },
slots: { default: 'Save' },
})
// Spinner is a direct child of button, not inside content wrapper
const spinner = wrapper.find('button > .os-button__spinner')
expect(spinner.exists()).toBeTruthy()
})
it('does not render button-level spinner when icon is present', () => {
const wrapper = mount(OsButton, {
props: { loading: true },
slots: {
icon: '<svg></svg>',
default: 'Save',
},
})
// No spinner as direct child of button — it's inside the icon wrapper
const buttonSpinner = wrapper.find('button > .os-button__spinner')
expect(buttonSpinner.exists()).toBeFalsy()
})
it('keeps icon visible and shows spinner for icon-only loading', () => {
const wrapper = mount(OsButton, {
props: { loading: true },
slots: { icon: '<svg data-testid="icon"></svg>' },
})
const iconWrapper = wrapper.find('.os-button__icon')
expect(iconWrapper.exists()).toBeTruthy()
expect(iconWrapper.find('[data-testid="icon"]').exists()).toBeTruthy()
expect(iconWrapper.find('.os-button__spinner').exists()).toBeTruthy()
expect(iconWrapper.classes()).not.toContain('[&>*]:invisible')
})
it('works with circle prop', () => {
const wrapper = mount(OsButton, {
props: { loading: true, circle: true },
slots: { icon: '<svg></svg>' },
attrs: { 'aria-label': 'Add' },
})
expect(wrapper.classes()).toContain('rounded-full')
expect(wrapper.find('.os-button__spinner').exists()).toBeTruthy()
expect(wrapper.attributes('disabled')).toBeDefined()
expect(wrapper.attributes('aria-busy')).toBe('true')
})
})
describe('keyboard accessibility', () => {
it('renders as native button element for keyboard support', () => {
const wrapper = mount(OsButton)
@ -152,6 +515,15 @@ describe('osButton', () => {
expect(wrapper.attributes('disabled')).toBeDefined()
})
it('icon-only button is focusable with aria-label', () => {
const wrapper = mount(OsButton, {
slots: { icon: '<svg></svg>' },
attrs: { 'aria-label': 'Close' },
})
expect(wrapper.attributes('aria-label')).toBe('Close')
expect(wrapper.attributes('tabindex')).toBeUndefined()
})
it('can receive focus programmatically', () => {
const wrapper = mount(OsButton, { attachTo: document.body })
const button = wrapper.element as HTMLButtonElement

View File

@ -1,7 +1,36 @@
import { computed, h } from 'vue'
import OsButton from './OsButton.vue'
import type { Meta, StoryObj } from '@storybook/vue3-vite'
/**
* Inline SVG icons for demo purposes (from Heroicons).
* In real usage, the webapp passes its own BaseIcon component.
*/
const CheckIcon = () =>
h('svg', { xmlns: 'http://www.w3.org/2000/svg', viewBox: '0 0 20 20', fill: 'currentColor' }, [
h('path', {
'fill-rule': 'evenodd',
d: 'M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z',
'clip-rule': 'evenodd',
}),
])
const CloseIcon = () =>
h('svg', { xmlns: 'http://www.w3.org/2000/svg', viewBox: '0 0 20 20', fill: 'currentColor' }, [
h('path', {
d: 'M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z',
}),
])
const PlusIcon = () =>
h('svg', { xmlns: 'http://www.w3.org/2000/svg', viewBox: '0 0 20 20', fill: 'currentColor' }, [
h('path', {
d: 'M10.75 4.75a.75.75 0 00-1.5 0v4.5h-4.5a.75.75 0 000 1.5h4.5v4.5a.75.75 0 001.5 0v-4.5h4.5a.75.75 0 000-1.5h-4.5v-4.5z',
}),
])
const meta: Meta<typeof OsButton> = {
title: 'Components/OsButton',
component: OsButton,
@ -11,7 +40,27 @@ const meta: Meta<typeof OsButton> = {
export default meta
type Story = StoryObj<typeof OsButton>
export const Playground: Story = {
/** Custom args for Playground (icon selector + label are not real component props) */
interface PlaygroundArgs {
variant: string
appearance: string
size: string
fullWidth: boolean
circle: boolean
disabled: boolean
loading: boolean
icon: string
label: string
}
const iconMap: Record<string, (() => ReturnType<typeof h>) | null> = {
none: null,
check: CheckIcon,
close: CloseIcon,
plus: PlusIcon,
}
export const Playground: StoryObj<PlaygroundArgs> = {
argTypes: {
variant: {
control: 'select',
@ -28,23 +77,51 @@ export const Playground: Story = {
fullWidth: {
control: 'boolean',
},
circle: {
control: 'boolean',
},
disabled: {
control: 'boolean',
},
loading: {
control: 'boolean',
},
icon: {
control: 'select',
options: Object.keys(iconMap),
},
label: {
control: 'text',
},
},
args: {
variant: 'primary',
appearance: 'filled',
size: 'md',
fullWidth: false,
circle: false,
disabled: false,
loading: false,
icon: 'none',
label: 'Button',
},
render: (args) => ({
components: { OsButton },
setup() {
return { args }
const buttonProps = computed(() => {
const { icon: _icon, label: _label, ...rest } = args
return rest
})
const IconComponent = computed(() => iconMap[args.icon] ?? null)
const label = computed(() => args.label)
return { buttonProps, IconComponent, label }
},
template: '<OsButton v-bind="args">Button</OsButton>',
template: `
<OsButton v-bind="buttonProps">
<template v-if="IconComponent" #icon><component :is="IconComponent" /></template>
{{ label }}
</OsButton>
`,
}),
}
@ -285,3 +362,344 @@ export const FullWidth: Story = {
`,
}),
}
export const Icon: Story = {
render: () => ({
components: { OsButton, CheckIcon, PlusIcon, CloseIcon },
template: `
<div class="flex flex-wrap gap-2">
<OsButton variant="primary">
<template #icon><CheckIcon /></template>
Save
</OsButton>
<OsButton variant="success">
<template #icon><CheckIcon /></template>
Confirm
</OsButton>
<OsButton variant="default">
<template #icon><PlusIcon /></template>
Add
</OsButton>
<OsButton variant="danger">
<template #icon><CloseIcon /></template>
Delete
</OsButton>
<OsButton variant="info">
<template #icon><PlusIcon /></template>
Create
</OsButton>
</div>
`,
}),
}
export const IconOnly: Story = {
render: () => ({
components: { OsButton, CloseIcon, PlusIcon, CheckIcon },
template: `
<div class="flex flex-wrap gap-2">
<OsButton variant="danger" aria-label="Close">
<template #icon><CloseIcon /></template>
</OsButton>
<OsButton variant="primary" aria-label="Add">
<template #icon><PlusIcon /></template>
</OsButton>
<OsButton variant="success" aria-label="Confirm">
<template #icon><CheckIcon /></template>
</OsButton>
<OsButton variant="default" aria-label="Close" appearance="outline">
<template #icon><CloseIcon /></template>
</OsButton>
<OsButton variant="primary" aria-label="Add" appearance="ghost">
<template #icon><PlusIcon /></template>
</OsButton>
</div>
`,
}),
}
export const IconSizes: Story = {
render: () => ({
components: { OsButton, CheckIcon },
template: `
<div class="flex flex-wrap gap-2 items-center">
<OsButton size="sm" variant="primary">
<template #icon><CheckIcon /></template>
Small
</OsButton>
<OsButton size="md" variant="primary">
<template #icon><CheckIcon /></template>
Medium
</OsButton>
<OsButton size="lg" variant="primary">
<template #icon><CheckIcon /></template>
Large
</OsButton>
<OsButton size="xl" variant="primary">
<template #icon><CheckIcon /></template>
Extra Large
</OsButton>
</div>
`,
}),
}
export const IconAppearances: Story = {
render: () => ({
components: { OsButton, CheckIcon },
template: `
<div class="flex flex-col gap-4">
<div>
<h3 class="text-sm font-bold mb-2">Filled</h3>
<div class="flex flex-wrap gap-2">
<OsButton appearance="filled" variant="primary">
<template #icon><CheckIcon /></template>
Primary
</OsButton>
<OsButton appearance="filled" variant="danger">
<template #icon><CheckIcon /></template>
Danger
</OsButton>
<OsButton appearance="filled" variant="success">
<template #icon><CheckIcon /></template>
Success
</OsButton>
</div>
</div>
<div>
<h3 class="text-sm font-bold mb-2">Outline</h3>
<div class="flex flex-wrap gap-2">
<OsButton appearance="outline" variant="primary">
<template #icon><CheckIcon /></template>
Primary
</OsButton>
<OsButton appearance="outline" variant="danger">
<template #icon><CheckIcon /></template>
Danger
</OsButton>
<OsButton appearance="outline" variant="success">
<template #icon><CheckIcon /></template>
Success
</OsButton>
</div>
</div>
<div>
<h3 class="text-sm font-bold mb-2">Ghost</h3>
<div class="flex flex-wrap gap-2">
<OsButton appearance="ghost" variant="primary">
<template #icon><CheckIcon /></template>
Primary
</OsButton>
<OsButton appearance="ghost" variant="danger">
<template #icon><CheckIcon /></template>
Danger
</OsButton>
<OsButton appearance="ghost" variant="success">
<template #icon><CheckIcon /></template>
Success
</OsButton>
</div>
</div>
</div>
`,
}),
}
export const Circle: Story = {
render: () => ({
components: { OsButton, PlusIcon, CloseIcon, CheckIcon },
template: `
<div class="flex flex-wrap gap-2 items-center">
<OsButton circle variant="default" aria-label="Add">
<template #icon><PlusIcon /></template>
</OsButton>
<OsButton circle variant="primary" aria-label="Add">
<template #icon><PlusIcon /></template>
</OsButton>
<OsButton circle variant="secondary" aria-label="Confirm">
<template #icon><CheckIcon /></template>
</OsButton>
<OsButton circle variant="danger" aria-label="Close">
<template #icon><CloseIcon /></template>
</OsButton>
<OsButton circle variant="warning" aria-label="Close">
<template #icon><CloseIcon /></template>
</OsButton>
<OsButton circle variant="success" aria-label="Confirm">
<template #icon><CheckIcon /></template>
</OsButton>
<OsButton circle variant="info" aria-label="Add">
<template #icon><PlusIcon /></template>
</OsButton>
</div>
`,
}),
}
export const CircleSizes: Story = {
render: () => ({
components: { OsButton, PlusIcon },
template: `
<div class="flex flex-col gap-4">
<div>
<h3 class="text-sm font-bold mb-2">Small (26px)</h3>
<div class="flex flex-wrap gap-2 items-center">
<OsButton circle size="sm" variant="primary" aria-label="Add">
<template #icon><PlusIcon /></template>
</OsButton>
<OsButton circle size="sm" variant="danger" aria-label="Add">
<template #icon><PlusIcon /></template>
</OsButton>
<OsButton circle size="sm" variant="default" aria-label="Add">
<template #icon><PlusIcon /></template>
</OsButton>
</div>
</div>
<div>
<h3 class="text-sm font-bold mb-2">Medium (36px)</h3>
<div class="flex flex-wrap gap-2 items-center">
<OsButton circle size="md" variant="primary" aria-label="Add">
<template #icon><PlusIcon /></template>
</OsButton>
<OsButton circle size="md" variant="danger" aria-label="Add">
<template #icon><PlusIcon /></template>
</OsButton>
<OsButton circle size="md" variant="default" aria-label="Add">
<template #icon><PlusIcon /></template>
</OsButton>
</div>
</div>
<div>
<h3 class="text-sm font-bold mb-2">Large (48px)</h3>
<div class="flex flex-wrap gap-2 items-center">
<OsButton circle size="lg" variant="primary" aria-label="Add">
<template #icon><PlusIcon /></template>
</OsButton>
<OsButton circle size="lg" variant="danger" aria-label="Add">
<template #icon><PlusIcon /></template>
</OsButton>
<OsButton circle size="lg" variant="default" aria-label="Add">
<template #icon><PlusIcon /></template>
</OsButton>
</div>
</div>
<div>
<h3 class="text-sm font-bold mb-2">Extra Large (56px)</h3>
<div class="flex flex-wrap gap-2 items-center">
<OsButton circle size="xl" variant="primary" aria-label="Add">
<template #icon><PlusIcon /></template>
</OsButton>
<OsButton circle size="xl" variant="danger" aria-label="Add">
<template #icon><PlusIcon /></template>
</OsButton>
<OsButton circle size="xl" variant="default" aria-label="Add">
<template #icon><PlusIcon /></template>
</OsButton>
</div>
</div>
</div>
`,
}),
}
export const CircleAppearances: Story = {
render: () => ({
components: { OsButton, PlusIcon, CloseIcon, CheckIcon },
template: `
<div class="flex flex-col gap-4">
<div>
<h3 class="text-sm font-bold mb-2">Filled</h3>
<div class="flex flex-wrap gap-2 items-center">
<OsButton circle appearance="filled" variant="primary" aria-label="Add">
<template #icon><PlusIcon /></template>
</OsButton>
<OsButton circle appearance="filled" variant="danger" aria-label="Close">
<template #icon><CloseIcon /></template>
</OsButton>
<OsButton circle appearance="filled" variant="success" aria-label="Confirm">
<template #icon><CheckIcon /></template>
</OsButton>
<OsButton circle appearance="filled" variant="default" aria-label="Add">
<template #icon><PlusIcon /></template>
</OsButton>
</div>
</div>
<div>
<h3 class="text-sm font-bold mb-2">Outline</h3>
<div class="flex flex-wrap gap-2 items-center">
<OsButton circle appearance="outline" variant="primary" aria-label="Add">
<template #icon><PlusIcon /></template>
</OsButton>
<OsButton circle appearance="outline" variant="danger" aria-label="Close">
<template #icon><CloseIcon /></template>
</OsButton>
<OsButton circle appearance="outline" variant="success" aria-label="Confirm">
<template #icon><CheckIcon /></template>
</OsButton>
<OsButton circle appearance="outline" variant="default" aria-label="Add">
<template #icon><PlusIcon /></template>
</OsButton>
</div>
</div>
<div>
<h3 class="text-sm font-bold mb-2">Ghost</h3>
<div class="flex flex-wrap gap-2 items-center">
<OsButton circle appearance="ghost" variant="primary" aria-label="Add">
<template #icon><PlusIcon /></template>
</OsButton>
<OsButton circle appearance="ghost" variant="danger" aria-label="Close">
<template #icon><CloseIcon /></template>
</OsButton>
<OsButton circle appearance="ghost" variant="success" aria-label="Confirm">
<template #icon><CheckIcon /></template>
</OsButton>
<OsButton circle appearance="ghost" variant="default" aria-label="Add">
<template #icon><PlusIcon /></template>
</OsButton>
</div>
</div>
</div>
`,
}),
}
export const Loading: Story = {
render: () => ({
components: { OsButton, CheckIcon },
template: `
<div class="flex flex-col gap-4">
<div>
<h3 class="text-sm font-bold mb-2">Filled</h3>
<div class="flex flex-wrap gap-2">
<OsButton loading appearance="filled" variant="default">Default</OsButton>
<OsButton loading appearance="filled" variant="primary">Primary</OsButton>
<OsButton loading appearance="filled" variant="danger">Danger</OsButton>
<OsButton loading appearance="filled" variant="success">Success</OsButton>
</div>
</div>
<div>
<h3 class="text-sm font-bold mb-2">Outline</h3>
<div class="flex flex-wrap gap-2">
<OsButton loading appearance="outline" variant="default">Default</OsButton>
<OsButton loading appearance="outline" variant="primary">Primary</OsButton>
<OsButton loading appearance="outline" variant="danger">Danger</OsButton>
<OsButton loading appearance="outline" variant="success">Success</OsButton>
</div>
</div>
<div>
<h3 class="text-sm font-bold mb-2">With Icon</h3>
<div class="flex flex-wrap gap-2">
<OsButton loading variant="primary">
<template #icon><CheckIcon /></template>
Save
</OsButton>
<OsButton loading variant="danger">Delete</OsButton>
<OsButton loading circle variant="primary" aria-label="Loading">
<template #icon><CheckIcon /></template>
</OsButton>
</div>
</div>
</div>
`,
}),
}

View File

@ -122,4 +122,85 @@ test.describe('OsButton visual regression', () => {
await expect(root.locator('.flex-col').first()).toHaveScreenshot('full-width.png')
await checkA11y(page)
})
test('icon', async ({ page }) => {
await page.goto(`${STORY_URL}--icon&viewMode=story`)
const root = page.locator(STORY_ROOT)
await root.waitFor()
await waitForFonts(page)
await expect(root.locator('.flex')).toHaveScreenshot('icon.png')
await checkA11y(page)
})
test('icon only', async ({ page }) => {
await page.goto(`${STORY_URL}--icon-only&viewMode=story`)
const root = page.locator(STORY_ROOT)
await root.waitFor()
await waitForFonts(page)
await expect(root.locator('.flex')).toHaveScreenshot('icon-only.png')
await checkA11y(page)
})
test('icon sizes', async ({ page }) => {
await page.goto(`${STORY_URL}--icon-sizes&viewMode=story`)
const root = page.locator(STORY_ROOT)
await root.waitFor()
await waitForFonts(page)
await expect(root.locator('.flex')).toHaveScreenshot('icon-sizes.png')
await checkA11y(page)
})
test('icon appearances', async ({ page }) => {
await page.goto(`${STORY_URL}--icon-appearances&viewMode=story`)
const root = page.locator(STORY_ROOT)
await root.waitFor()
await waitForFonts(page)
await expect(root.locator('.flex-col').first()).toHaveScreenshot('icon-appearances.png')
await checkA11y(page)
})
test('circle', async ({ page }) => {
await page.goto(`${STORY_URL}--circle&viewMode=story`)
const root = page.locator(STORY_ROOT)
await root.waitFor()
await waitForFonts(page)
await expect(root.locator('.flex')).toHaveScreenshot('circle.png')
await checkA11y(page)
})
test('circle sizes', async ({ page }) => {
await page.goto(`${STORY_URL}--circle-sizes&viewMode=story`)
const root = page.locator(STORY_ROOT)
await root.waitFor()
await waitForFonts(page)
await expect(root.locator('.flex-col').first()).toHaveScreenshot('circle-sizes.png')
await checkA11y(page)
})
test('circle appearances', async ({ page }) => {
await page.goto(`${STORY_URL}--circle-appearances&viewMode=story`)
const root = page.locator(STORY_ROOT)
await root.waitFor()
await waitForFonts(page)
await expect(root.locator('.flex-col').first()).toHaveScreenshot('circle-appearances.png')
await checkA11y(page)
})
test('loading', async ({ page }) => {
await page.goto(`${STORY_URL}--loading&viewMode=story`)
const root = page.locator(STORY_ROOT)
await root.waitFor()
await waitForFonts(page)
// Pause animations for deterministic screenshots
await page.evaluate(() => {
document.querySelectorAll('.os-button__spinner').forEach((el) => {
;(el as HTMLElement).style.animationPlayState = 'paused'
})
document.querySelectorAll('.os-button__spinner circle').forEach((el) => {
;(el as HTMLElement).style.animationPlayState = 'paused'
})
})
await expect(root.locator('.flex-col').first()).toHaveScreenshot('loading.png')
await checkA11y(page)
})
})

View File

@ -8,9 +8,64 @@
import type { ButtonVariants } from './button.variants'
import type { PropType } from 'vue-demi'
/**
* Flexible button component with optional icon slot.
* @slot default - Button content (text or HTML)
* @slot icon - Optional icon (rendered left of text). Use aria-label for icon-only buttons.
*/
type Size = NonNullable<ButtonVariants['size']>
const CIRCLE_WIDTHS: Record<Size, string> = {
sm: 'w-[26px]',
md: 'w-[36px]',
lg: 'w-12',
xl: 'w-14',
}
const ICON_CLASS =
'os-button__icon inline-flex items-center shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current'
const SPINNER_PX: Record<Size, number> = { sm: 24, md: 32, lg: 40, xl: 46 }
const SVG_ATTRS = {
viewBox: '0 0 50 50',
xmlns: 'http://www.w3.org/2000/svg',
'aria-hidden': 'true',
}
const CIRCLE_ATTRS = {
cx: '25',
cy: '25',
r: '20',
fill: 'none',
stroke: 'currentColor',
'stroke-width': '4',
'stroke-linecap': 'round',
}
const CIRCLE_STYLE =
'transform-origin:25px 25px;animation:os-spinner-rotate 16s linear infinite,os-spinner-dash 1.5s ease-in-out infinite'
function vueAttrs(attrs: Record<string, string>, style?: string) {
/* v8 ignore start -- Vue 2 branch tested in webapp Jest tests */
return isVue2 ? { attrs, ...(style && { style }) } : { ...attrs, ...(style && { style }) }
/* v8 ignore stop */
}
function createSpinner(px: number, center: string) {
const svg = h('svg', vueAttrs(SVG_ATTRS, 'width:100%;height:100%;overflow:hidden'), [
h('circle', vueAttrs(CIRCLE_ATTRS, CIRCLE_STYLE)),
])
return h(
'span',
{ class: `os-button__spinner absolute ${center}`, style: `width:${px}px;height:${px}px` },
[svg],
)
}
export default defineComponent({
name: 'OsButton',
// In Vue 2, inheritAttrs must be false to manually forward attrs
inheritAttrs: false,
props: {
variant: {
@ -37,61 +92,131 @@
type: Boolean,
default: false,
},
circle: {
type: Boolean,
default: false,
},
loading: {
type: Boolean,
default: false,
},
},
setup(props, { slots, attrs }) {
const classes = computed(() =>
cn(
buttonVariants({
variant: props.variant,
appearance: props.appearance,
size: props.size,
fullWidth: props.fullWidth,
}),
),
const variantClasses = computed(() =>
buttonVariants({
variant: props.variant,
appearance: props.appearance,
size: props.size,
fullWidth: props.fullWidth,
}),
)
// Get component instance for Vue 2 $listeners access
const instance = getCurrentInstance()
/* v8 ignore start -- Vue 2 only */
const instance = isVue2 ? getCurrentInstance() : null
/* v8 ignore stop */
return () => {
const children = slots.default?.()
const iconContent = slots.icon?.()
const defaultContent = slots.default?.()
const hasIcon = iconContent && iconContent.length > 0
const hasText =
defaultContent?.some((node: unknown) => {
const children = (node as Record<string, unknown>).children
return typeof children !== 'string' || children.trim().length > 0
}) ?? false
const size = props.size as Size
const spinnerPx = SPINNER_PX[size] // eslint-disable-line security/detect-object-injection
const isSmall = props.circle || size === 'sm'
const isLoading = props.loading
const isDisabled = props.disabled || isLoading
// --- Build inner children (icon + text) ---
const innerChildren: ReturnType<typeof h>[] = []
if (hasIcon) {
const iconChildren = isLoading
? [
/* v8 ignore next -- iconContent guaranteed truthy by hasIcon check */
...(iconContent || []),
createSpinner(spinnerPx, 'top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2'),
]
: iconContent
innerChildren.push(
h(
'span',
{
class: cn(
ICON_CLASS,
!isSmall && (hasText ? '-ml-1' : '-ml-1 -mr-1'),
isLoading && 'relative overflow-visible',
),
},
iconChildren,
),
)
}
if (defaultContent && hasText) {
innerChildren.push(...(defaultContent as ReturnType<typeof h>[]))
}
const contentWrapper = h(
'span',
{
class: cn(
'inline-flex items-center',
hasIcon && hasText && (isSmall ? 'gap-1' : 'gap-2'),
),
},
innerChildren,
)
const children =
isLoading && !hasIcon
? [contentWrapper, createSpinner(spinnerPx, 'inset-0 m-auto')]
: [contentWrapper]
const buttonClass = cn(
variantClasses.value,
props.circle && 'rounded-full p-0',
props.circle && CIRCLE_WIDTHS[size], // eslint-disable-line security/detect-object-injection
)
const buttonData = {
type: props.type,
disabled: isDisabled || undefined,
'data-variant': props.variant,
'data-appearance': props.appearance,
'aria-busy': isLoading || undefined,
}
/* v8 ignore start -- Vue 2 branch tested in webapp Jest tests */
if (isVue2) {
// Vue 2: separate attrs and on (listeners)
// $listeners contains event handlers like @click
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const proxy = instance?.proxy as any
const listeners = proxy?.$listeners || {}
// In Vue 2, class/style are not in $attrs - access via $vnode
const parentClass = proxy?.$vnode?.data?.staticClass || ''
const parentDynClass = proxy?.$vnode?.data?.class
return h(
'button',
{
class: [classes.value, parentClass, parentDynClass].filter(Boolean),
attrs: {
type: props.type,
disabled: props.disabled || undefined,
'data-appearance': props.appearance,
...attrs,
},
class: [buttonClass, parentClass, parentDynClass].filter(Boolean),
attrs: { ...buttonData, ...attrs },
on: listeners,
},
children,
)
}
/* v8 ignore stop */
// Vue 3: flat props, attrs includes listeners
// Extract class from attrs to merge instead of overwrite
const { class: attrClass, ...restAttrs } = attrs as Record<string, unknown>
return h(
'button',
{
type: props.type,
disabled: props.disabled,
'data-appearance': props.appearance,
class: cn(classes.value, (attrClass as string) || ''),
...buttonData,
class: cn(buttonClass, attrClass || ''),
...restAttrs,
},
children,

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@ -45,7 +45,7 @@ export const buttonVariants = cva(
],
outline: [
'bg-transparent shadow-none',
// Disabled: gray border and text
// Disabled: light gray border and gray text
'disabled:border-[var(--color-disabled)] disabled:text-[var(--color-disabled)]',
],
ghost: [
@ -55,10 +55,10 @@ export const buttonVariants = cva(
],
},
size: {
sm: 'h-[26px] px-[8px] py-0 text-[12px] leading-[normal] tracking-[0.6px] rounded-[5px] overflow-hidden whitespace-nowrap align-middle', // base-button --small
md: 'h-[36px] px-[16px] py-0 text-[15px] leading-[normal] rounded-[5px] align-middle',
lg: 'h-12 px-6 py-3 text-lg',
xl: 'h-14 px-8 py-4 text-xl',
sm: 'h-[26px] min-w-[26px] px-[8px] py-0 text-[12px] leading-[normal] tracking-[0.6px] rounded-[5px] overflow-hidden whitespace-nowrap align-middle',
md: 'h-[36px] min-w-[36px] px-[16px] py-0 text-[15px] leading-[normal] rounded-[5px] align-middle',
lg: 'h-12 min-w-12 px-6 py-3 text-lg',
xl: 'h-14 min-w-14 px-8 py-4 text-xl',
},
fullWidth: {
true: 'w-full',

View File

@ -0,0 +1,29 @@
/*
* Spinner animations for loading state
*/
@keyframes os-spinner-dash {
0% {
stroke-dasharray: 1, 150;
stroke-dashoffset: 0;
}
50% {
stroke-dasharray: 90, 150;
stroke-dashoffset: -35;
}
100% {
stroke-dasharray: 90, 150;
stroke-dashoffset: -124;
}
}
@keyframes os-spinner-rotate {
from {
transform: rotate(0deg);
}
to {
transform: rotate(2160deg);
}
}

View File

@ -9,6 +9,7 @@
*/
@import "tailwindcss/theme";
@import "tailwindcss/utilities";
@import "./animations.css";
/* Scan component files for utility classes */
@source "../components/**/*.vue";

View File

@ -1,20 +1,27 @@
<template>
<div class="action-button">
<base-button
<os-button
variant="primary"
:appearance="filled ? 'filled' : 'outline'"
:loading="loading"
:disabled="disabled"
:icon="icon"
:aria-label="text"
:filled="filled"
circle
@click="click"
/>
>
<template #icon>
<base-icon :name="icon" />
</template>
</os-button>
<div class="count">{{ count }}</div>
</div>
</template>
<script>
import { OsButton } from '@ocelot-social/ui'
export default {
components: { OsButton },
props: {
count: { type: Number, required: true },
text: { type: String, required: true },

View File

@ -29,18 +29,18 @@ describe('FollowButton.vue', () => {
it('renders button and text', () => {
expect(mocks.$t).toHaveBeenCalledWith('followButton.follow')
expect(wrapper.findAll('.base-button')).toHaveLength(1)
expect(wrapper.findAll('[data-test="follow-btn"]')).toHaveLength(1)
})
it('renders button and text when followed', () => {
propsData.isFollowed = true
wrapper = Wrapper()
expect(mocks.$t).toHaveBeenCalledWith('followButton.following')
expect(wrapper.findAll('.base-button')).toHaveLength(1)
expect(wrapper.findAll('[data-test="follow-btn"]')).toHaveLength(1)
})
it.skip('toggle the button', async () => {
wrapper.find('.base-button').trigger('click') // This does not work since @click.prevent is used
wrapper.find('[data-test="follow-btn"]').trigger('click') // This does not work since @click.prevent is used
expect(wrapper.vm.isFollowed).toBe(true)
})
})

View File

@ -1,24 +1,29 @@
<template>
<base-button
class="track-button"
<os-button
data-test="follow-btn"
:variant="isFollowed && hovered ? 'danger' : 'primary'"
:appearance="isFollowed && !hovered ? 'filled' : 'outline'"
:disabled="disabled || !followId"
:loading="loading"
:icon="icon"
:filled="isFollowed && !hovered"
:danger="isFollowed && hovered"
@mouseenter.native="onHover"
@mouseleave.native="hovered = false"
full-width
@mouseenter="onHover"
@mouseleave="hovered = false"
@click.prevent="toggle"
>
<template #icon>
<base-icon :name="icon" />
</template>
{{ label }}
</base-button>
</os-button>
</template>
<script>
import { OsButton } from '@ocelot-social/ui'
import { followUserMutation, unfollowUserMutation } from '~/graphql/User'
export default {
name: 'HcFollowButton',
components: { OsButton },
props: {
followId: { type: String, default: null },
isFollowed: { type: Boolean, default: false },
@ -82,10 +87,3 @@ export default {
},
}
</script>
<style lang="scss">
.track-button {
display: block;
width: 100%;
}
</style>

View File

@ -1,26 +1,31 @@
<template>
<base-button
class="join-leave-button"
<os-button
data-test="join-leave-btn"
:variant="isMember && hovered ? 'danger' : 'primary'"
:appearance="filled || (isMember && !hovered) ? 'filled' : 'outline'"
:disabled="disabled"
:loading="localLoading"
:icon="icon"
:filled="filled || (isMember && !hovered)"
:danger="isMember && hovered"
full-width
v-tooltip="tooltip"
@mouseenter.native="onHover"
@mouseleave.native="hovered = false"
@mouseenter="onHover"
@mouseleave="hovered = false"
@click.prevent="toggle"
>
<template #icon>
<base-icon :name="icon" />
</template>
{{ label }}
</base-button>
</os-button>
</template>
<script>
import { mapMutations } from 'vuex'
import { OsButton } from '@ocelot-social/ui'
import { joinGroupMutation, leaveGroupMutation } from '~/graphql/groups'
export default {
name: 'JoinLeaveButton',
components: { OsButton },
props: {
group: { type: Object, required: true },
userId: { type: String, required: true },
@ -146,10 +151,3 @@ export default {
},
}
</script>
<style lang="scss">
.join-leave-button {
display: block;
width: 100%;
}
</style>

View File

@ -1,7 +1,7 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`JoinLeaveButton.vue shallowMount renders 1`] = `
<base-button-stub icon="plus" size="regular" type="button" class="join-leave-button has-tooltip" data-original-title="null">
<os-button-stub variant="primary" appearance="outline" size="md" fullwidth="true" type="button" data-original-title="null" class=" has-tooltip">
group.joinLeaveButton.join
</base-button-stub>
</os-button-stub>
`;

View File

@ -1,25 +1,27 @@
<template>
<section class="categories-select">
<base-button
<os-button
v-for="category in sortCategories(categories)"
:key="category.id"
:data-test="categoryButtonsId(category.id)"
@click="toggleCategory(category.id)"
:filled="isActive(category.id)"
variant="primary"
:appearance="isActive(category.id) ? 'filled' : 'outline'"
:disabled="isDisabled(category.id)"
:icon="category.icon"
size="small"
size="sm"
v-tooltip="{
content: $t(`contribution.category.description.${category.slug}`),
placement: 'bottom-start',
}"
>
<template #icon><base-icon :name="category.icon" /></template>
{{ $t(`contribution.category.name.${category.slug}`) }}
</base-button>
</os-button>
</section>
</template>
<script>
import { OsButton } from '@ocelot-social/ui'
import { CATEGORIES_MAX } from '~/constants/categories.js'
import xor from 'lodash/xor'
import SortCategories from '~/mixins/sortCategoriesMixin.js'
@ -31,6 +33,7 @@ export default {
default: null,
},
},
components: { OsButton },
mixins: [SortCategories, GetCategories],
props: {
existingCategoryIds: { type: Array, default: () => [] },
@ -83,7 +86,7 @@ export default {
display: flex;
flex-wrap: wrap;
> .base-button {
> button {
margin-right: $space-xx-small;
margin-bottom: $space-xx-small;
}

View File

@ -2,7 +2,19 @@
<div class="add-chat-room-by-user-search">
<ds-flex class="headline">
<h2 class="title">{{ $t('chat.addRoomHeadline') }}</h2>
<base-button class="close-button" icon="close" circle size="small" @click="closeUserSearch" />
<os-button
class="close-button"
variant="primary"
appearance="ghost"
circle
size="sm"
:aria-label="$t('actions.close')"
@click="closeUserSearch"
>
<template #icon>
<base-icon name="close" />
</template>
</os-button>
</ds-flex>
<ds-space margin-bottom="small" />
<ds-space>
@ -12,11 +24,13 @@
</template>
<script>
import { OsButton } from '@ocelot-social/ui'
import SelectUserSearch from '~/components/generic/SelectUserSearch/SelectUserSearch'
export default {
name: 'AddChatRoomByUserSearch',
components: {
OsButton,
SelectUserSearch,
},
props: {

View File

@ -35,18 +35,34 @@
<ds-flex v-if="singleRoom">
<ds-flex-item centered class="single-chat-bubble">
<nuxt-link :to="{ name: 'chat' }">
<base-button icon="expand" size="small" circle />
<os-button
variant="primary"
appearance="ghost"
circle
size="sm"
:aria-label="$t('chat.expandChat')"
>
<template #icon>
<base-icon name="expand" />
</template>
</os-button>
</nuxt-link>
</ds-flex-item>
<ds-flex-item centered>
<div class="vac-svg-button vac-room-options">
<slot name="menu-icon">
<base-button
icon="close"
size="small"
<os-button
variant="primary"
appearance="ghost"
circle
size="sm"
:aria-label="$t('chat.closeChat')"
@click="$emit('close-single-room', true)"
/>
>
<template #icon>
<base-icon name="close" />
</template>
</os-button>
</slot>
</div>
</ds-flex-item>
@ -89,6 +105,7 @@
</template>
<script>
import { OsButton } from '@ocelot-social/ui'
import { roomQuery, createRoom, unreadRoomsQuery } from '~/graphql/Rooms'
import {
messageQuery,
@ -101,6 +118,7 @@ import { mapGetters, mapMutations } from 'vuex'
export default {
name: 'Chat',
components: { OsButton },
props: {
theme: {
type: String,

View File

@ -1,19 +1,24 @@
<template>
<nuxt-link class="chat-notification-menu" :to="{ name: 'chat' }">
<base-button
ghost
<os-button
variant="primary"
appearance="ghost"
circle
:aria-label="$t('header.chats.tooltip')"
v-tooltip="{
content: $t('header.chats.tooltip'),
placement: 'bottom-start',
}"
>
<counter-icon icon="chat-bubble" :count="unreadRoomCount" danger />
</base-button>
<template #icon>
<counter-icon icon="chat-bubble" :count="unreadRoomCount" danger />
</template>
</os-button>
</nuxt-link>
</template>
<script>
import { OsButton } from '@ocelot-social/ui'
import { mapGetters, mapMutations } from 'vuex'
import CounterIcon from '~/components/_new/generic/CounterIcon/CounterIcon'
import { unreadRoomsQuery, roomCountUpdated } from '~/graphql/Rooms'
@ -21,6 +26,7 @@ import { unreadRoomsQuery, roomCountUpdated } from '~/graphql/Rooms'
export default {
name: 'ChatNotificationMenu',
components: {
OsButton,
CounterIcon,
},
computed: {

View File

@ -54,15 +54,21 @@
class="shout-button"
node-type="Comment"
/>
<base-button
<os-button
:title="$t('post.comment.reply')"
icon="level-down"
:aria-label="$t('post.comment.reply')"
class="reply-button"
variant="primary"
appearance="outline"
circle
size="small"
size="sm"
v-scroll-to="'.editor'"
@click="reply"
/>
>
<template #icon>
<base-icon name="level-down" />
</template>
</os-button>
</div>
</base-card>
</template>

View File

@ -4,16 +4,24 @@
<base-card>
<hc-editor ref="editor" :users="users" :value="form.content" @input="updateEditorContent" />
<div class="buttons">
<base-button
<os-button
variant="primary"
appearance="outline"
:disabled="disabled && !update"
@click="handleCancel"
data-test="cancel-button"
>
{{ $t('actions.cancel') }}
</base-button>
<base-button type="submit" :loading="loading" :disabled="disabled || errors" filled>
</os-button>
<os-button
variant="primary"
appearance="filled"
type="submit"
:loading="loading"
:disabled="disabled || !!errors"
>
{{ $t('post.comment.submit') }}
</base-button>
</os-button>
</div>
</base-card>
</template>
@ -21,6 +29,7 @@
</template>
<script>
import { OsButton } from '@ocelot-social/ui'
import HcEditor from '~/components/Editor/Editor'
import { COMMENT_MIN_LENGTH } from '~/constants/comment'
import { minimisedUserQuery } from '~/graphql/User'
@ -28,6 +37,7 @@ import CommentMutations from '~/graphql/CommentMutations'
export default {
components: {
OsButton,
HcEditor,
},
props: {
@ -79,54 +89,49 @@ export default {
this.closeEditWindow()
}
},
handleSubmit() {
let mutateParams
if (!this.update) {
mutateParams = {
mutation: CommentMutations(this.$i18n).CreateComment,
variables: {
postId: this.post.id,
content: this.form.content,
},
}
} else {
mutateParams = {
mutation: CommentMutations(this.$i18n).UpdateComment,
variables: {
id: this.comment.id,
content: this.form.content,
},
}
}
async handleSubmit() {
const mutateParams = !this.update
? {
mutation: CommentMutations(this.$i18n).CreateComment,
variables: {
postId: this.post.id,
content: this.form.content,
},
}
: {
mutation: CommentMutations(this.$i18n).UpdateComment,
variables: {
id: this.comment.id,
content: this.form.content,
},
}
this.loading = true
this.disabled = true
this.$apollo
.mutate(mutateParams)
.then((res) => {
this.loading = false
if (!this.update) {
const {
data: { CreateComment },
} = res
this.$emit('createComment', CreateComment)
this.clear()
this.$toast.success(this.$t('post.comment.submitted'))
this.disabled = false
} else {
const {
data: { UpdateComment },
} = res
this.$emit('updateComment', UpdateComment)
this.$emit('collapse')
this.$toast.success(this.$t('post.comment.updated'))
this.disabled = false
this.closeEditWindow()
}
})
.catch((err) => {
this.$toast.error(err.message)
})
try {
const res = await this.$apollo.mutate(mutateParams)
if (!this.update) {
const {
data: { CreateComment },
} = res
this.$emit('createComment', CreateComment)
this.clear()
this.$toast.success(this.$t('post.comment.submitted'))
} else {
const {
data: { UpdateComment },
} = res
this.$emit('updateComment', UpdateComment)
this.$emit('collapse')
this.$toast.success(this.$t('post.comment.updated'))
this.closeEditWindow()
}
} catch (err) {
this.$toast.error(err.message)
this.disabled = false
} finally {
this.loading = false
}
},
},
apollo: {
@ -152,7 +157,7 @@ export default {
display: flex;
justify-content: flex-end;
> .base-button {
> button {
margin-left: $space-x-small;
}
}

View File

@ -56,7 +56,7 @@ describe('ComponentSlider.vue', () => {
})
it('click on next Button', async () => {
await wrapper.find('.base-button[data-test="next-button"]').trigger('click')
await wrapper.find('button[data-test="next-button"]').trigger('click')
await wrapper.vm.$nextTick()
expect(propsData.sliderData.sliderSelectorCallback).toHaveBeenCalled()
})

View File

@ -35,26 +35,26 @@
:key="slider.name"
:class="['Sliders__slider-selection', index === sliderIndex && '--unconfirmed']"
>
<base-button
<os-button
:class="['selection-dot']"
style="float: left"
:bullet="true"
size="tiny"
type="submit"
filled
:loading="false"
variant="primary"
:appearance="index <= sliderIndex ? 'filled' : 'outline'"
circle
size="sm"
:disabled="index > sliderIndex"
:aria-label="
$t('component-slider.step', { current: index + 1, total: sliderData.sliders.length })
"
@click="sliderData.sliderSelectorCallback(index)"
/>
</div>
</ds-flex-item>
<ds-flex-item>
<base-button
<os-button
:style="multipleSliders && 'float: right'"
:icon="sliderData.sliders[sliderIndex].button.icon"
type="submit"
filled
padding
variant="primary"
appearance="filled"
:loading="
sliderData.sliders[sliderIndex].button.loading !== undefined
? sliderData.sliders[sliderIndex].button.loading
@ -64,8 +64,11 @@
@click="onNextClick"
data-test="next-button"
>
<template v-if="sliderData.sliders[sliderIndex].button.icon" #icon>
<base-icon :name="sliderData.sliders[sliderIndex].button.icon" />
</template>
{{ $t(sliderData.sliders[sliderIndex].button.titleIdent) }}
</base-button>
</os-button>
</ds-flex-item>
</ds-flex>
@ -74,7 +77,10 @@
</template>
<script>
import { OsButton } from '@ocelot-social/ui'
export default {
components: { OsButton },
name: 'ComponentSlider',
props: {
sliderData: { type: Object, required: true },
@ -107,6 +113,10 @@ export default {
&__slider-selection {
.selection-dot {
margin-right: 2px;
height: 18px !important;
width: 18px !important;
min-height: 18px !important;
min-width: 18px !important;
}
&.--unconfirmed {
opacity: $opacity-disabled;

View File

@ -2,13 +2,19 @@
<dropdown class="content-menu" :placement="placement" offset="5">
<template #default="{ toggleMenu }">
<slot name="button" :toggleMenu="toggleMenu">
<base-button
<os-button
data-test="content-menu-button"
icon="ellipsis-v"
size="small"
variant="primary"
appearance="outline"
size="sm"
circle
:aria-label="$t('actions.menu')"
@click.prevent="toggleMenu()"
/>
>
<template #icon>
<base-icon name="ellipsis-v" />
</template>
</os-button>
</slot>
</template>
<template #popover="{ toggleMenu }">
@ -31,12 +37,14 @@
</template>
<script>
import { OsButton } from '@ocelot-social/ui'
import Dropdown from '~/components/Dropdown'
import PinnedPostsMixin from '~/mixins/pinnedPosts'
export default {
name: 'ContentMenu',
components: {
OsButton,
Dropdown,
},
mixins: [PinnedPostsMixin],

View File

@ -2,13 +2,19 @@
<dropdown class="group-content-menu" :placement="placement" offset="5">
<template #default="{ toggleMenu }">
<slot name="button" :toggleMenu="toggleMenu">
<base-button
icon="ellipsis-v"
size="small"
<os-button
variant="primary"
appearance="outline"
size="sm"
circle
@click.prevent="toggleMenu()"
:aria-label="$t('group.contentMenu.menuButton')"
data-test="group-menu-button"
/>
@click.prevent="toggleMenu()"
>
<template #icon>
<base-icon name="ellipsis-v" />
</template>
</os-button>
</slot>
</template>
<template #popover="{ toggleMenu }">
@ -32,11 +38,13 @@
</template>
<script>
import { OsButton } from '@ocelot-social/ui'
import Dropdown from '~/components/Dropdown'
export default {
name: 'GroupContentMenu',
components: {
OsButton,
Dropdown,
},
props: {

View File

@ -21,18 +21,26 @@ exports[`GroupContentMenu renders as groupProfile when I am the owner 1`] = `
trigger="manual"
>
<button
class="base-button --icon-only --circle --small"
aria-label="group.contentMenu.menuButton"
class="inline-flex items-center justify-center [white-space-collapse:collapse] relative appearance-none font-semibold transition-[color,background-color] duration-[80ms] ease-[cubic-bezier(0.25,0.46,0.45,0.94)] cursor-pointer select-none border-[0.8px] border-solid focus:outline-1 disabled:pointer-events-none disabled:cursor-default focus:outline-dashed focus:outline-[var(--color-primary)] bg-transparent shadow-none disabled:border-[var(--color-disabled)] disabled:text-[var(--color-disabled)] h-[26px] min-w-[26px] text-[12px] leading-[normal] tracking-[0.6px] overflow-hidden whitespace-nowrap align-middle border-[var(--color-primary)] text-[var(--color-primary)] hover:bg-[var(--color-primary)] hover:text-[var(--color-primary-contrast)] active:bg-[var(--color-primary-active)] active:border-[var(--color-primary-active)] active:text-[var(--color-primary-contrast)] rounded-full p-0 w-[26px]"
data-appearance="outline"
data-test="group-menu-button"
data-variant="primary"
type="button"
>
<span
class="base-icon"
class="inline-flex items-center"
>
<!---->
<span
class="os-button__icon inline-flex items-center shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current"
>
<span
class="base-icon"
>
<!---->
</span>
</span>
</span>
<!---->
</button>
<div>
@ -138,18 +146,26 @@ exports[`GroupContentMenu renders as groupProfile, muted 1`] = `
trigger="manual"
>
<button
class="base-button --icon-only --circle --small"
aria-label="group.contentMenu.menuButton"
class="inline-flex items-center justify-center [white-space-collapse:collapse] relative appearance-none font-semibold transition-[color,background-color] duration-[80ms] ease-[cubic-bezier(0.25,0.46,0.45,0.94)] cursor-pointer select-none border-[0.8px] border-solid focus:outline-1 disabled:pointer-events-none disabled:cursor-default focus:outline-dashed focus:outline-[var(--color-primary)] bg-transparent shadow-none disabled:border-[var(--color-disabled)] disabled:text-[var(--color-disabled)] h-[26px] min-w-[26px] text-[12px] leading-[normal] tracking-[0.6px] overflow-hidden whitespace-nowrap align-middle border-[var(--color-primary)] text-[var(--color-primary)] hover:bg-[var(--color-primary)] hover:text-[var(--color-primary-contrast)] active:bg-[var(--color-primary-active)] active:border-[var(--color-primary-active)] active:text-[var(--color-primary-contrast)] rounded-full p-0 w-[26px]"
data-appearance="outline"
data-test="group-menu-button"
data-variant="primary"
type="button"
>
<span
class="base-icon"
class="inline-flex items-center"
>
<!---->
<span
class="os-button__icon inline-flex items-center shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current"
>
<span
class="base-icon"
>
<!---->
</span>
</span>
</span>
<!---->
</button>
<div>
@ -213,18 +229,26 @@ exports[`GroupContentMenu renders as groupProfile, not muted 1`] = `
trigger="manual"
>
<button
class="base-button --icon-only --circle --small"
aria-label="group.contentMenu.menuButton"
class="inline-flex items-center justify-center [white-space-collapse:collapse] relative appearance-none font-semibold transition-[color,background-color] duration-[80ms] ease-[cubic-bezier(0.25,0.46,0.45,0.94)] cursor-pointer select-none border-[0.8px] border-solid focus:outline-1 disabled:pointer-events-none disabled:cursor-default focus:outline-dashed focus:outline-[var(--color-primary)] bg-transparent shadow-none disabled:border-[var(--color-disabled)] disabled:text-[var(--color-disabled)] h-[26px] min-w-[26px] text-[12px] leading-[normal] tracking-[0.6px] overflow-hidden whitespace-nowrap align-middle border-[var(--color-primary)] text-[var(--color-primary)] hover:bg-[var(--color-primary)] hover:text-[var(--color-primary-contrast)] active:bg-[var(--color-primary-active)] active:border-[var(--color-primary-active)] active:text-[var(--color-primary-contrast)] rounded-full p-0 w-[26px]"
data-appearance="outline"
data-test="group-menu-button"
data-variant="primary"
type="button"
>
<span
class="base-icon"
class="inline-flex items-center"
>
<!---->
<span
class="os-button__icon inline-flex items-center shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current"
>
<span
class="base-icon"
>
<!---->
</span>
</span>
</span>
<!---->
</button>
<div>
@ -288,18 +312,26 @@ exports[`GroupContentMenu renders as groupTeaser 1`] = `
trigger="manual"
>
<button
class="base-button --icon-only --circle --small"
aria-label="group.contentMenu.menuButton"
class="inline-flex items-center justify-center [white-space-collapse:collapse] relative appearance-none font-semibold transition-[color,background-color] duration-[80ms] ease-[cubic-bezier(0.25,0.46,0.45,0.94)] cursor-pointer select-none border-[0.8px] border-solid focus:outline-1 disabled:pointer-events-none disabled:cursor-default focus:outline-dashed focus:outline-[var(--color-primary)] bg-transparent shadow-none disabled:border-[var(--color-disabled)] disabled:text-[var(--color-disabled)] h-[26px] min-w-[26px] text-[12px] leading-[normal] tracking-[0.6px] overflow-hidden whitespace-nowrap align-middle border-[var(--color-primary)] text-[var(--color-primary)] hover:bg-[var(--color-primary)] hover:text-[var(--color-primary-contrast)] active:bg-[var(--color-primary-active)] active:border-[var(--color-primary-active)] active:text-[var(--color-primary-contrast)] rounded-full p-0 w-[26px]"
data-appearance="outline"
data-test="group-menu-button"
data-variant="primary"
type="button"
>
<span
class="base-icon"
class="inline-flex items-center"
>
<!---->
<span
class="os-button__icon inline-flex items-center shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current"
>
<span
class="base-icon"
>
<!---->
</span>
</span>
</span>
<!---->
</button>
<div>

View File

@ -182,9 +182,18 @@
>
{{ $t('actions.cancel') }}
</os-button>
<base-button type="submit" icon="check" :loading="loading" :disabled="errors" filled>
<os-button
variant="primary"
appearance="filled"
type="submit"
:loading="loading"
:disabled="!!errors"
>
<template #icon>
<base-icon name="check" />
</template>
{{ $t('actions.save') }}
</base-button>
</os-button>
</ds-flex-item>
</ds-flex>
</base-card>

View File

@ -1,10 +1,12 @@
<template>
<div>
<a v-if="settings.url" :href="settings.url" :target="settings.target">
<base-button
<os-button
class="custom-button"
variant="primary"
appearance="ghost"
circle
ghost
:aria-label="$t(settings.toolTipIdent)"
v-tooltip="{
content: $t(settings.toolTipIdent),
placement: 'bottom-start',
@ -16,13 +18,15 @@
:alt="settings.iconAltText"
:style="logoWidthStyle"
/>
</base-button>
</os-button>
</a>
<nuxt-link v-else :to="settings.path">
<base-button
<os-button
class="custom-button"
variant="primary"
appearance="ghost"
circle
ghost
:aria-label="$t(settings.toolTipIdent)"
v-tooltip="{
content: $t(settings.toolTipIdent),
placement: 'bottom-start',
@ -34,27 +38,24 @@
:alt="settings.iconAltText"
:style="logoWidthStyle"
/>
</base-button>
</os-button>
</nuxt-link>
</div>
</template>
<script>
import { OsButton } from '@ocelot-social/ui'
import isEmpty from 'lodash/isEmpty'
export default {
components: { OsButton },
name: 'CustomButton',
props: {
settings: { type: Object, required: true },
},
data() {
return {
isEmpty,
}
},
computed: {
logoWidthStyle() {
const width = this.isEmpty(this.settings.iconWidth) ? '26px' : this.settings.iconWidth
const width = isEmpty(this.settings.iconWidth) ? '26px' : this.settings.iconWidth
return `width: ${width};`
},
},

View File

@ -42,26 +42,30 @@
<section class="warning">
<p>{{ $t('settings.deleteUserAccount.accountWarning') }}</p>
</section>
<base-button
icon="trash"
danger
filled
<os-button
variant="danger"
appearance="filled"
:disabled="!deleteEnabled"
data-test="delete-button"
@click="handleSubmit"
>
<template #icon>
<base-icon name="trash" />
</template>
{{ $t('settings.deleteUserAccount.name') }}
</base-button>
</os-button>
</base-card>
</template>
<script>
import { OsButton } from '@ocelot-social/ui'
import { mapActions, mapGetters } from 'vuex'
import gql from 'graphql-tag'
import { currentUserCountQuery } from '~/graphql/User'
export default {
name: 'DeleteData',
components: { OsButton },
data() {
return {
deleteContributions: false,
@ -166,7 +170,7 @@ export default {
border-left: 4px solid $color-danger;
}
> .base-button {
> button {
align-self: flex-start;
}
}

View File

@ -10,7 +10,6 @@ export default {
displayContextMenu(target, content, type) {
const placement = type === 'link' ? 'right' : 'top-start'
const trigger = type === 'link' ? 'click' : 'mouseenter'
const showOnInit = type !== 'link'
if (this.menu) {
return
@ -24,7 +23,6 @@ export default {
inertia: true,
interactive: true,
placement,
showOnInit,
theme: 'ocelot-social',
trigger,
onMount(instance) {
@ -35,6 +33,7 @@ export default {
}
},
})
this.menu.show()
// we have to update tippy whenever the DOM is updated
if (MutationObserver) {
@ -50,8 +49,9 @@ export default {
},
hideContextMenu() {
if (this.menu) {
this.menu.destroy()
const menu = this.menu
this.menu = null
menu.destroy()
}
if (this.observer) {
this.observer.disconnect()

View File

@ -248,12 +248,17 @@ export default {
this.editor.commands.mention({ id: message.id, label: message.slug })
},
toggleLinkInput(attrs, element) {
if (!this.isLinkInputActive && attrs && element) {
if (this.$refs.contextMenu.menu) {
this.$refs.contextMenu.hideContextMenu()
this.isLinkInputActive = false
this.editor.focus()
} else if (attrs && element) {
this.$refs.linkInput.linkUrl = attrs.href
this.isLinkInputActive = true
this.$refs.contextMenu.displayContextMenu(element, this.$refs.linkInput.$el, 'link')
this.$nextTick(() => {
this.$refs.contextMenu.displayContextMenu(element, this.$refs.linkInput.$el, 'link')
})
} else {
this.$refs.contextMenu.hideContextMenu()
this.isLinkInputActive = false
this.editor.focus()
}

View File

@ -1,64 +1,144 @@
<template>
<editor-menu-bar :editor="editor" v-slot="{ commands, isActive, getMarkAttrs }">
<div>
<menu-bar-button :isActive="isActive.bold()" :onClick="commands.bold" icon="bold" />
<os-button
size="sm"
circle
variant="primary"
:appearance="isActive.bold() ? 'outline' : 'ghost'"
:aria-label="$t('editor.legend.bold')"
@click="commands.bold"
>
<template #icon>
<base-icon name="bold" />
</template>
</os-button>
<menu-bar-button :isActive="isActive.italic()" :onClick="commands.italic" icon="italic" />
<os-button
size="sm"
circle
variant="primary"
:appearance="isActive.italic() ? 'outline' : 'ghost'"
:aria-label="$t('editor.legend.italic')"
@click="commands.italic"
>
<template #icon>
<base-icon name="italic" />
</template>
</os-button>
<menu-bar-button
:isActive="isActive.underline()"
:onClick="commands.underline"
icon="underline"
/>
<os-button
size="sm"
circle
variant="primary"
:appearance="isActive.underline() ? 'outline' : 'ghost'"
:aria-label="$t('editor.legend.underline')"
@click="commands.underline"
>
<template #icon>
<base-icon name="underline" />
</template>
</os-button>
<menu-bar-button
ref="linkButton"
:isActive="isActive.link()"
:onClick="(event) => toggleLinkInput(getMarkAttrs('link'), event.currentTarget)"
icon="link"
/>
<os-button
size="sm"
circle
variant="primary"
:appearance="isActive.link() ? 'outline' : 'ghost'"
:aria-label="$t('editor.legend.link')"
@click="(event) => toggleLinkInput(getMarkAttrs('link'), event.target.closest('button'))"
>
<template #icon>
<base-icon name="link" />
</template>
</os-button>
<menu-bar-button
:isActive="isActive.paragraph()"
:onClick="commands.paragraph"
icon="paragraph"
/>
<os-button
size="sm"
circle
variant="primary"
:appearance="isActive.paragraph() ? 'outline' : 'ghost'"
:aria-label="$t('editor.legend.paragraph')"
@click="commands.paragraph"
>
<template #icon>
<base-icon name="paragraph" />
</template>
</os-button>
<menu-bar-button
:isActive="isActive.heading({ level: 3 })"
:onClick="() => commands.heading({ level: 3 })"
label="H3"
/>
<os-button
size="sm"
circle
variant="primary"
:appearance="isActive.heading({ level: 3 }) ? 'outline' : 'ghost'"
:aria-label="$t('editor.legend.heading3')"
@click="() => commands.heading({ level: 3 })"
>
H3
</os-button>
<menu-bar-button
:isActive="isActive.heading({ level: 4 })"
:onClick="() => commands.heading({ level: 4 })"
label="H4"
/>
<os-button
size="sm"
circle
variant="primary"
:appearance="isActive.heading({ level: 4 }) ? 'outline' : 'ghost'"
:aria-label="$t('editor.legend.heading4')"
@click="() => commands.heading({ level: 4 })"
>
H4
</os-button>
<menu-bar-button
:isActive="isActive.bullet_list()"
:onClick="commands.bullet_list"
icon="list-ul"
/>
<os-button
size="sm"
circle
variant="primary"
:appearance="isActive.bullet_list() ? 'outline' : 'ghost'"
:aria-label="$t('editor.legend.unorderedList')"
@click="commands.bullet_list"
>
<template #icon>
<base-icon name="list-ul" />
</template>
</os-button>
<menu-bar-button
:isActive="isActive.ordered_list()"
:onClick="commands.ordered_list"
icon="list-ol"
/>
<os-button
size="sm"
circle
variant="primary"
:appearance="isActive.ordered_list() ? 'outline' : 'ghost'"
:aria-label="$t('editor.legend.orderedList')"
@click="commands.ordered_list"
>
<template #icon>
<base-icon name="list-ol" />
</template>
</os-button>
<menu-bar-button
:isActive="isActive.blockquote()"
:onClick="commands.blockquote"
icon="quote-right"
/>
<os-button
size="sm"
circle
variant="primary"
:appearance="isActive.blockquote() ? 'outline' : 'ghost'"
:aria-label="$t('editor.legend.quote')"
@click="commands.blockquote"
>
<template #icon>
<base-icon name="quote-right" />
</template>
</os-button>
<menu-bar-button
:isActive="isActive.horizontal_rule()"
:onClick="commands.horizontal_rule"
icon="minus"
/>
<os-button
size="sm"
circle
variant="primary"
:appearance="isActive.horizontal_rule() ? 'outline' : 'ghost'"
:aria-label="$t('editor.legend.ruler')"
@click="commands.horizontal_rule"
>
<template #icon>
<base-icon name="minus" />
</template>
</os-button>
<menu-legend class="legend-button" />
</div>
@ -66,14 +146,14 @@
</template>
<script>
import { OsButton } from '@ocelot-social/ui'
import { EditorMenuBar } from 'tiptap'
import MenuBarButton from './MenuBarButton'
import MenuLegend from './MenuLegend.vue'
export default {
components: {
OsButton,
EditorMenuBar,
MenuBarButton,
MenuLegend,
},
props: {

View File

@ -1,16 +0,0 @@
<template>
<base-button size="small" circle :ghost="!isActive" @click="onClick" :icon="icon">
<span v-if="label">{{ label }}</span>
</base-button>
</template>
<script>
export default {
props: {
isActive: Boolean,
icon: String,
label: String,
onClick: { type: Function, default: () => {} },
},
}
</script>

View File

@ -1,19 +1,19 @@
<template>
<dropdown :placement="placement" offset="5">
<template #default="{ openMenu, closeMenu }">
<template #default="{ toggleMenu }">
<slot name="button">
<menu-bar-button
<os-button
class="legend-question-button"
icon="question-circle"
variant="primary"
appearance="ghost"
circle
ghost
:onClick="
() => {
isDropdownOpen ? closeMenu() : openMenu()
isDropdownOpen = !isDropdownOpen
}
"
/>
size="sm"
@click="toggleMenu"
>
<template #icon>
<base-icon name="question-circle" />
</template>
</os-button>
</slot>
</template>
<!-- eslint-disable-next-line vue/no-useless-template-attributes -->
@ -26,9 +26,12 @@
:key="item.name"
>
<div>
<base-button size="small" circle ghost :icon="item.iconName" class="legend-icon">
<os-button size="sm" circle variant="primary" appearance="ghost" class="legend-icon">
<template v-if="item.iconName" #icon>
<base-icon :name="item.iconName" />
</template>
<span v-if="item.label">{{ item.label }}</span>
</base-button>
</os-button>
<span>{{ $t(item.name) }}</span>
</div>
<span class="tool-shortcut">{{ item.shortcut }}</span>
@ -39,13 +42,13 @@
</template>
<script>
import { OsButton } from '@ocelot-social/ui'
import Dropdown from '~/components/Dropdown'
import MenuBarButton from './MenuBarButton'
export default {
components: {
Dropdown,
MenuBarButton,
OsButton,
},
props: {
placement: { type: String, default: 'bottom-start' },
@ -65,20 +68,19 @@ export default {
{ iconName: 'quote-right', name: `editor.legend.quote`, shortcut: '> + space' },
{ iconName: 'minus', name: `editor.legend.ruler`, shortcut: '---' },
],
isDropdownOpen: false,
}
},
}
</script>
<style lang="scss">
<style lang="scss" scoped>
.legend-question-button {
color: $color-neutral-40;
color: #70677e !important;
font-size: 1.2rem !important;
}
.legend-question-button:hover {
background: none !important;
color: $color-neutral-40 !important;
color: #70677e !important;
}
.legend-question-button:focus {
outline: none !important;

View File

@ -47,13 +47,19 @@
<span>{{ $t('editor.embed.always_allow') }}</span>
</label>
</aside>
<base-button
icon="close"
size="small"
<os-button
variant="primary"
appearance="outline"
circle
size="sm"
class="close-button"
:aria-label="$t('actions.close')"
@click.prevent="removeEmbed()"
/>
>
<template #icon>
<base-icon name="close" />
</template>
</os-button>
</ds-container>
</template>
@ -252,9 +258,9 @@ export default {
}
> .close-button {
position: absolute;
top: $space-x-small;
right: $space-x-small;
position: absolute !important;
top: $space-x-small !important;
right: $space-x-small !important;
}
}

View File

@ -1,15 +1,24 @@
<template>
<div class="emotion-button">
<base-button :id="emotion" circle ghost @click="$emit('toggleEmotion', emotion)">
<os-button
:id="emotion"
appearance="ghost"
circle
:aria-label="$t(`contribution.emotions-label.${emotion}`)"
@click="$emit('toggleEmotion', emotion)"
>
<img class="image" :src="emojiPath" />
</base-button>
</os-button>
<label class="label" :for="emotion">{{ $t(`contribution.emotions-label.${emotion}`) }}</label>
<p v-if="emotionCount !== null" class="count">{{ emotionCount }}x</p>
</div>
</template>
<script>
import { OsButton } from '@ocelot-social/ui'
export default {
components: { OsButton },
name: 'EmotionButton',
props: {
emojiPath: {
@ -35,7 +44,7 @@ export default {
flex-direction: column;
align-items: center;
> .base-button {
> button {
padding: 0;
&:hover {

View File

@ -63,7 +63,7 @@ describe('CtaJoinLeaveGroup.vue', () => {
},
},
})
wrapper.find('.base-button').trigger('click')
wrapper.find('[data-test="join-leave-btn"]').trigger('click')
await wrapper.vm.$nextTick()
})
it('emits update event', async () => {

View File

@ -8,24 +8,28 @@
{{ $t('contribution.comment.commenting-disabled.blocked-author.call-to-action') }}
</ds-text>
<nuxt-link :to="authorLink">
<base-button icon="arrow-right" filled>
<os-button variant="primary" appearance="filled">
<template #icon><base-icon name="arrow-right" /></template>
{{
$t('contribution.comment.commenting-disabled.blocked-author.button-label', {
name: author.name,
})
}}
</base-button>
</os-button>
</nuxt-link>
</ds-space>
</template>
<script>
import { OsButton } from '@ocelot-social/ui'
export default {
name: 'CtaUnblockAuthor',
components: { OsButton },
props: {
author: {
type: Object,
require: true,
required: true,
},
},
computed: {

View File

@ -11,9 +11,8 @@ exports[`CtaJoinLeaveGroup.vue mount renders 1`] = `
</h4>
<p class="ds-text">
contribution.comment.commenting-disabled.no-group-member.call-to-action
</p> <button type="button" class="join-leave-button base-button --filled has-tooltip" data-original-title="null"><span class="base-icon"><!----></span>
<!---->
</p> <button type="button" data-variant="primary" data-appearance="filled" class="inline-flex items-center justify-center [white-space-collapse:collapse] relative appearance-none font-semibold tracking-[0.05em] transition-[color,background-color] duration-[80ms] ease-[cubic-bezier(0.25,0.46,0.45,0.94)] cursor-pointer select-none border-[0.8px] border-solid focus:outline-1 disabled:pointer-events-none disabled:cursor-default focus:outline-dashed focus:outline-[var(--color-primary)] shadow-[inset_0_0_0_1px_rgba(0,0,0,0.05)] disabled:bg-[var(--color-disabled)] disabled:text-[var(--color-disabled-contrast)] disabled:border-[var(--color-disabled)] disabled:shadow-[inset_0_0_0_1px_transparent] h-[36px] min-w-[36px] px-[16px] py-0 text-[15px] leading-[normal] rounded-[5px] align-middle w-full bg-[var(--color-primary)] text-[var(--color-primary-contrast)] border-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] hover:border-[var(--color-primary-hover)] hover:text-[var(--color-primary-contrast)] active:bg-[var(--color-primary-active)] active:border-[var(--color-primary-active)] active:text-[var(--color-primary-contrast)] has-tooltip" data-original-title="null"><span class="inline-flex items-center gap-2"><span class="os-button__icon inline-flex items-center shrink-0 h-[1.2em] [&amp;>svg]:h-full [&amp;>svg]:w-auto [&amp;>svg]:fill-current -ml-1"><span class="base-icon"><!----></span></span>
group.joinLeaveButton.join
</button>
</span></button>
</div>
`;

View File

@ -10,9 +10,9 @@ exports[`CtaUnblockAuthor.vue shallowMount renders 1`] = `
contribution.comment.commenting-disabled.blocked-author.call-to-action
</ds-text-stub>
<nuxt-link-stub to="[object Object]">
<base-button-stub filled="true" icon="arrow-right" size="regular" type="button">
<os-button-stub variant="primary" appearance="filled" size="md" type="button">
contribution.comment.commenting-disabled.blocked-author.button-label
</base-button-stub>
</os-button-stub>
</nuxt-link-stub>
</ds-space-stub>
`;

View File

@ -20,7 +20,13 @@
<ds-text>
{{ $t('components.registration.email-nonce.form.click-next') }}
</ds-text>
<os-button variant="primary" :disabled="disabled" name="submit" type="submit">
<os-button
variant="primary"
appearance="filled"
:disabled="disabled"
name="submit"
type="submit"
>
{{ $t('components.registration.email-nonce.form.next') }}
</os-button>
<slot></slot>

View File

@ -60,21 +60,24 @@ describe('CategoriesFilter.vue', () => {
describe('mount', () => {
it('starts with all categories button active', () => {
const allCategoriesButton = wrapper.find('.categories-filter .item-all-topics .base-button')
expect(allCategoriesButton.attributes().class).toContain('--filled')
expect(
wrapper
.find('.categories-filter .item-all-topics button[data-appearance="filled"]')
.exists(),
).toBe(true)
})
// TODO move to FilterMenuComponent.spec.js?
// it('sets category button attribute `filled` when corresponding category is filtered', async () => {
// getters['posts/filteredCategoryIds'] = jest.fn(() => ['cat9'])
// wrapper = await Wrapper()
// democracyAndPoliticsButton = wrapper.find('.categories-filter .item-save-topics .base-button')
// democracyAndPoliticsButton = wrapper.find('.categories-filter .item-save-topics button')
// expect(democracyAndPoliticsButton.attributes().class).toContain('--filled')
// })
describe('click on an "catetories-buttons" button', () => {
it('calls TOGGLE_CATEGORY when clicked', () => {
environmentAndNatureButton = wrapper.findAll('.category-filter-list .base-button').at(0)
environmentAndNatureButton = wrapper.findAll('.category-filter-list button').at(0)
environmentAndNatureButton.trigger('click')
expect(mutations['posts/TOGGLE_CATEGORY']).toHaveBeenCalledWith({}, 'cat15')
})
@ -84,7 +87,7 @@ describe('CategoriesFilter.vue', () => {
it('when all button is clicked', async () => {
getters['posts/filteredCategoryIds'] = jest.fn(() => ['cat9'])
wrapper = await Wrapper()
const allCategoriesButton = wrapper.find('.categories-filter .item-all-topics .base-button')
const allCategoriesButton = wrapper.find('.categories-filter .item-all-topics button')
allCategoriesButton.trigger('click')
expect(mutations['posts/RESET_CATEGORIES']).toHaveBeenCalledTimes(1)
})
@ -94,7 +97,7 @@ describe('CategoriesFilter.vue', () => {
// describe('save categories', () => {
// it('calls the API', async () => {
// wrapper = await Wrapper()
// const saveButton = wrapper.find('.categories-filter .item-save-topics .base-button')
// const saveButton = wrapper.find('.categories-filter .item-save-topics button')
// saveButton.trigger('click')
// expect(apolloMutationMock).toBeCalled()
// })

View File

@ -2,38 +2,44 @@
<filter-menu-section :title="$t('filter-menu.categories')" class="categories-filter">
<template #filter-list>
<div class="item item-all-topics">
<base-button
:filled="!filteredCategoryIds.length"
:label="$t('filter-menu.all')"
icon="check"
<os-button
variant="primary"
:appearance="!filteredCategoryIds.length ? 'filled' : 'outline'"
size="sm"
@click="setResetCategories"
size="small"
>
<template #icon>
<base-icon name="check" />
</template>
{{ $t('filter-menu.all') }}
</base-button>
</os-button>
</div>
<div class="category-filter-list">
<!-- <ds-space margin="small" /> -->
<base-button
<os-button
v-for="category in sortCategories(categories)"
:key="category.id"
variant="primary"
:appearance="filteredCategoryIds.includes(category.id) ? 'filled' : 'outline'"
size="sm"
@click="saveCategories(category.id)"
:filled="filteredCategoryIds.includes(category.id)"
:icon="category.icon"
size="small"
v-tooltip="{
content: $t(`contribution.category.description.${category.slug}`),
placement: 'bottom-start',
}"
>
<template #icon>
<base-icon :name="category.icon" />
</template>
{{ $t(`contribution.category.name.${category.slug}`) }}
</base-button>
</os-button>
</div>
</template>
</filter-menu-section>
</template>
<script>
import { OsButton } from '@ocelot-social/ui'
import { mapGetters, mapMutations } from 'vuex'
import FilterMenuSection from '~/components/FilterMenu/FilterMenuSection'
import SortCategories from '~/mixins/sortCategoriesMixin.js'
@ -42,6 +48,7 @@ import GetCategories from '~/mixins/getCategoriesMixin.js'
export default {
components: {
FilterMenuSection,
OsButton,
},
mixins: [SortCategories, GetCategories],
computed: {
@ -69,7 +76,7 @@ export default {
.category-filter-list {
margin-left: $space-xx-small;
> .base-button {
> button {
margin-right: $space-xx-small;
margin-bottom: $space-xx-small;
}

View File

@ -45,14 +45,14 @@ describe('mount', () => {
// describe('mount', () => {
// it('starts with all emotions button active', () => {
// const allEmotionsButton = wrapper.find('.emotions-filter .sidebar .base-button')
// const allEmotionsButton = wrapper.find('.emotions-filter .sidebar button')
// expect(allEmotionsButton.attributes().class).toContain('--filled')
// })
// describe('click on an "emotion-button" button', () => {
// it('calls TOGGLE_EMOTION when clicked', () => {
// const wrapper = Wrapper()
// happyEmotionButton = wrapper.findAll('.emotion-button > .base-button').at(1)
// happyEmotionButton = wrapper.findAll('.emotion-button > button').at(1)
// happyEmotionButton.trigger('click')
// expect(mutations['posts/TOGGLE_EMOTION']).toHaveBeenCalledWith({}, 'happy')
// })
@ -60,7 +60,7 @@ describe('mount', () => {
// it('sets the attribute `src` to colorized image', () => {
// getters['posts/filteredByEmotions'] = jest.fn(() => ['happy'])
// const wrapper = Wrapper()
// happyEmotionButton = wrapper.findAll('.emotion-button > .base-button').at(1)
// happyEmotionButton = wrapper.findAll('.emotion-button > button').at(1)
// const happyEmotionButtonImage = happyEmotionButton.find('img')
// expect(happyEmotionButtonImage.attributes().src).toEqual('/img/svg/emoji/happy_color.svg')
// })
@ -70,7 +70,7 @@ describe('mount', () => {
// it('when all button is clicked', async () => {
// getters['posts/filteredByEmotions'] = jest.fn(() => ['happy'])
// wrapper = await Wrapper()
// const allEmotionsButton = wrapper.find('.emotions-filter .sidebar .base-button')
// const allEmotionsButton = wrapper.find('.emotions-filter .sidebar button')
// allEmotionsButton.trigger('click')
// expect(mutations['posts/RESET_EMOTIONS']).toHaveBeenCalledTimes(1)
// })

View File

@ -2,36 +2,41 @@
<filter-menu-section class="order-by-filter" :title="sectionTitle" :divider="false">
<template #filter-list>
<li class="item">
<base-button
icon="check"
:label="$t('filter-menu.ended.all.label')"
:filled="!eventsEnded"
<os-button
variant="primary"
:appearance="!eventsEnded ? 'filled' : 'outline'"
size="sm"
:title="$t('filter-menu.ended.all.hint')"
@click="toggleEventsEnded"
data-test="all-button"
size="small"
>
<template #icon>
<base-icon name="check" />
</template>
{{ $t('filter-menu.ended.all.label') }}
</base-button>
</os-button>
</li>
<li class="item">
<base-button
icon="calendar"
:label="$t('filter-menu.ended.onlyEnded.label')"
:filled="eventsEnded"
<os-button
variant="primary"
:appearance="eventsEnded ? 'filled' : 'outline'"
size="sm"
:title="$t('filter-menu.ended.onlyEnded.hint')"
@click="toggleEventsEnded"
data-test="not-ended-button"
size="small"
>
<template #icon>
<base-icon name="calendar" />
</template>
{{ $t('filter-menu.ended.onlyEnded.label') }}
</base-button>
</os-button>
</li>
</template>
</filter-menu-section>
</template>
<script>
import { OsButton } from '@ocelot-social/ui'
import { mapGetters, mapMutations } from 'vuex'
import FilterMenuSection from '~/components/FilterMenu/FilterMenuSection'
@ -39,6 +44,7 @@ export default {
name: 'EventsByFilter',
components: {
FilterMenuSection,
OsButton,
},
computed: {
...mapGetters({

View File

@ -39,15 +39,13 @@ describe('FilterMenu.vue', () => {
describe('mount', () => {
it('starts with dropdown button inactive', () => {
const dropdownButton = wrapper.find('.filter-menu .base-button')
expect(dropdownButton.attributes().class).toContain('--ghost')
expect(wrapper.find('.filter-menu button[data-appearance="ghost"]').exists()).toBe(true)
})
it('sets dropdwon button attribute `filled` when a filter is applied', () => {
it('sets dropdown button attribute `filled` when a filter is applied', () => {
getters['posts/isActive'] = jest.fn(() => true)
wrapper = Wrapper()
const dropdownButton = wrapper.find('.filter-menu .base-button')
expect(dropdownButton.attributes().class).toContain('--filled')
expect(wrapper.find('.filter-menu button[data-appearance="filled"]').exists()).toBe(true)
})
})
})

View File

@ -1,15 +1,18 @@
<template>
<dropdown ref="menu" placement="top-start" :offset="8" class="filter-menu">
<base-button
slot="default"
icon="filter"
:filled="filterActive"
:ghost="!filterActive"
slot-scope="{ toggleMenu }"
@click.prevent="toggleMenu()"
>
<base-icon class="dropdown-arrow" name="angle-down" />
</base-button>
<template #default="{ toggleMenu }">
<os-button
variant="primary"
:appearance="filterActive ? 'filled' : 'ghost'"
:aria-label="$t('common.filter')"
@click.prevent="toggleMenu()"
>
<template #icon>
<base-icon name="filter" />
</template>
<base-icon class="dropdown-arrow" name="angle-down" />
</os-button>
</template>
<template #popover>
<filter-menu-component />
</template>
@ -17,6 +20,7 @@
</template>
<script>
import { OsButton } from '@ocelot-social/ui'
import Dropdown from '~/components/Dropdown'
import { mapGetters } from 'vuex'
import FilterMenuComponent from './FilterMenuComponent'
@ -25,6 +29,7 @@ export default {
components: {
Dropdown,
FilterMenuComponent,
OsButton,
},
props: {
placement: { type: String },

View File

@ -44,19 +44,21 @@ describe('FollowingFilter', () => {
const wrapper = Wrapper()
expect(
wrapper
.find('.following-filter .filter-list .follower-item .base-button')
.classes('--filled'),
.find('.following-filter .filter-list .follower-item button[data-appearance="filled"]')
.exists(),
).toBe(true)
expect(
wrapper
.find('.following-filter .filter-list .posts-in-my-groups-item .base-button')
.classes('--filled'),
.find(
'.following-filter .filter-list .posts-in-my-groups-item button[data-appearance="filled"]',
)
.exists(),
).toBe(true)
})
describe('click "filter-by-followed" button', () => {
it('calls TOGGLE_FILTER_BY_FOLLOWED', () => {
wrapper.find('.following-filter .filter-list .follower-item .base-button').trigger('click')
wrapper.find('.following-filter .filter-list .follower-item button').trigger('click')
expect(mutations['posts/TOGGLE_FILTER_BY_FOLLOWED']).toHaveBeenCalledWith({}, 'u34')
})
})
@ -64,7 +66,7 @@ describe('FollowingFilter', () => {
describe('click "filter-by-my-groups" button', () => {
it('calls TOGGLE_FILTER_BY_MY_GROUPS', () => {
wrapper
.find('.following-filter .filter-list .posts-in-my-groups-item .base-button')
.find('.following-filter .filter-list .posts-in-my-groups-item button')
.trigger('click')
expect(mutations['posts/TOGGLE_FILTER_BY_MY_GROUPS']).toHaveBeenCalled()
})
@ -72,9 +74,7 @@ describe('FollowingFilter', () => {
describe('clears follower filter', () => {
it('when all button is clicked', async () => {
wrapper = await Wrapper()
const clearFollowerButton = wrapper.find(
'.following-filter .item-all-follower .base-button',
)
const clearFollowerButton = wrapper.find('.following-filter .item-all-follower button')
clearFollowerButton.trigger('click')
expect(mutations['posts/RESET_FOLLOWERS_FILTER']).toHaveBeenCalledTimes(1)
})

View File

@ -6,40 +6,48 @@
>
<template #filter-follower>
<div class="item item-all-follower">
<base-button
:filled="!filteredByUsersFollowed && !filteredByPostsInMyGroups"
:label="$t('filter-menu.all')"
icon="check"
<os-button
variant="primary"
:appearance="
!filteredByUsersFollowed && !filteredByPostsInMyGroups ? 'filled' : 'outline'
"
size="sm"
@click="setResetFollowers"
size="small"
>
<template #icon>
<base-icon name="check" />
</template>
{{ $t('filter-menu.all') }}
</base-button>
</os-button>
</div>
<div class="follower-filter-list">
<li class="item follower-item">
<base-button
icon="user-plus"
:label="$t('filter-menu.following')"
:filled="filteredByUsersFollowed"
<os-button
variant="primary"
:appearance="filteredByUsersFollowed ? 'filled' : 'outline'"
size="sm"
:title="$t('filter-menu.following')"
@click="toggleFilteredByFollowed(currentUser.id)"
size="small"
>
<template #icon>
<base-icon name="user-plus" />
</template>
{{ $t('filter-menu.following') }}
</base-button>
</os-button>
</li>
<li class="item posts-in-my-groups-item">
<base-button
icon="users"
:label="$t('filter-menu.my-groups')"
:filled="filteredByPostsInMyGroups"
<os-button
variant="primary"
:appearance="filteredByPostsInMyGroups ? 'filled' : 'outline'"
size="sm"
:title="$t('contribution.filterMyGroups')"
@click="toggleFilteredByMyGroups()"
size="small"
>
<template #icon>
<base-icon name="users" />
</template>
{{ $t('contribution.filterMyGroups') }}
</base-button>
</os-button>
</li>
</div>
</template>
@ -47,6 +55,7 @@
</template>
<script>
import { OsButton } from '@ocelot-social/ui'
import { mapGetters, mapMutations } from 'vuex'
import FilterMenuSection from '~/components/FilterMenu/FilterMenuSection'
@ -54,6 +63,7 @@ export default {
name: 'FollowingFilter',
components: {
FilterMenuSection,
OsButton,
},
computed: {
...mapGetters({
@ -81,7 +91,7 @@ export default {
display: flex;
margin-left: $space-xx-small;
& .base-button {
& button {
margin-right: $space-xx-small;
margin-bottom: $space-xx-small;
}

View File

@ -1,27 +1,30 @@
<template>
<span>
<base-button
class="my-filter-button my-filter-button-selected"
right
@click="clickButton"
filled
>
<span class="header-button-wrapper">
<os-button class="my-filter-button" variant="primary" appearance="filled" @click="clickButton">
{{ title }}
</base-button>
<base-button
</os-button>
<os-button
class="filter-remove"
@click="clickRemove"
icon="close"
:title="titleRemove"
size="small"
variant="primary"
appearance="filled"
circle
filled
/>
size="sm"
:title="titleRemove"
:aria-label="titleRemove"
@click.stop="clickRemove"
>
<template #icon>
<base-icon name="close" />
</template>
</os-button>
</span>
</template>
<script>
import { OsButton } from '@ocelot-social/ui'
export default {
name: 'HeaderButton',
components: { OsButton },
props: {
title: {
type: String,
@ -43,14 +46,21 @@ export default {
}
</script>
<style lang="scss">
.my-filter-button-selected {
padding-right: 36px;
}
.base-button.filter-remove {
.header-button-wrapper {
display: inline-flex;
align-items: center;
position: relative;
margin-left: -37px;
top: -5px;
margin-right: 8px;
> .my-filter-button {
padding-right: 36px !important;
}
> .filter-remove {
position: absolute !important;
right: 4px !important;
top: 50% !important;
transform: translateY(-50%);
}
}
</style>

View File

@ -48,7 +48,7 @@ describe('mount', () => {
// describe('mount', () => {
// it('starts with all categories button active', () => {
// const allLanguagesButton = wrapper.find('.languages-filter .sidebar .base-button')
// const allLanguagesButton = wrapper.find('.languages-filter .sidebar button')
// expect(allLanguagesButton.attributes().class).toContain('--filled')
// })
@ -56,7 +56,7 @@ describe('mount', () => {
// getters['posts/filteredLanguageCodes'] = jest.fn(() => ['es'])
// const wrapper = Wrapper()
// spanishButton = wrapper
// .findAll('.languages-filter .item .base-button')
// .findAll('.languages-filter .item button')
// .at(languages.findIndex((l) => l.code === 'es'))
// expect(spanishButton.attributes().class).toContain('--filled')
// })
@ -65,7 +65,7 @@ describe('mount', () => {
// it('calls TOGGLE_LANGUAGE when clicked', () => {
// const wrapper = Wrapper()
// englishButton = wrapper
// .findAll('.languages-filter .item .base-button')
// .findAll('.languages-filter .item button')
// .at(languages.findIndex((l) => l.code === 'en'))
// englishButton.trigger('click')
// expect(mutations['posts/TOGGLE_LANGUAGE']).toHaveBeenCalledWith({}, 'en')
@ -76,7 +76,7 @@ describe('mount', () => {
// it('when all button is clicked', async () => {
// getters['posts/filteredLanguageCodes'] = jest.fn(() => ['en'])
// wrapper = await Wrapper()
// const allLanguagesButton = wrapper.find('.languages-filter .sidebar .base-button')
// const allLanguagesButton = wrapper.find('.languages-filter .sidebar button')
// allLanguagesButton.trigger('click')
// expect(mutations['posts/RESET_LANGUAGES']).toHaveBeenCalledTimes(1)
// })

View File

@ -38,16 +38,20 @@ describe('OrderByFilter', () => {
it('sets "newest-button" attribute `filled`', () => {
expect(
wrapper
.find('.order-by-filter .filter-list .base-button[data-test="newest-button"]')
.classes('--filled'),
.find(
'.order-by-filter .filter-list button[data-test="newest-button"][data-appearance="filled"]',
)
.exists(),
).toBe(true)
})
it('don\'t sets "oldest-button" attribute `filled`', () => {
expect(
wrapper
.find('.order-by-filter .filter-list .base-button[data-test="oldest-button"]')
.classes('--filled'),
.find(
'.order-by-filter .filter-list button[data-test="oldest-button"][data-appearance="filled"]',
)
.exists(),
).toBe(false)
})
})
@ -61,16 +65,20 @@ describe('OrderByFilter', () => {
it('don\'t sets "newest-button" attribute `filled`', () => {
expect(
wrapper
.find('.order-by-filter .filter-list .base-button[data-test="newest-button"]')
.classes('--filled'),
.find(
'.order-by-filter .filter-list button[data-test="newest-button"][data-appearance="filled"]',
)
.exists(),
).toBe(false)
})
it('sets "oldest-button" attribute `filled`', () => {
expect(
wrapper
.find('.order-by-filter .filter-list .base-button[data-test="oldest-button"]')
.classes('--filled'),
.find(
'.order-by-filter .filter-list button[data-test="oldest-button"][data-appearance="filled"]',
)
.exists(),
).toBe(true)
})
})
@ -78,7 +86,7 @@ describe('OrderByFilter', () => {
describe('click "newest-button"', () => {
it('calls TOGGLE_ORDER with "sortDate_desc"', () => {
wrapper
.find('.order-by-filter .filter-list .base-button[data-test="newest-button"]')
.find('.order-by-filter .filter-list button[data-test="newest-button"]')
.trigger('click')
expect(mutations['posts/TOGGLE_ORDER']).toHaveBeenCalledWith({}, 'sortDate_desc')
})
@ -87,7 +95,7 @@ describe('OrderByFilter', () => {
describe('click "oldest-button"', () => {
it('calls TOGGLE_ORDER with "sortDate_asc"', () => {
wrapper
.find('.order-by-filter .filter-list .base-button[data-test="oldest-button"]')
.find('.order-by-filter .filter-list button[data-test="oldest-button"]')
.trigger('click')
expect(mutations['posts/TOGGLE_ORDER']).toHaveBeenCalledWith({}, 'sortDate_asc')
})

View File

@ -2,36 +2,41 @@
<filter-menu-section class="order-by-filter" :title="sectionTitle" :divider="false">
<template #filter-list>
<li class="item">
<base-button
icon="sort-amount-asc"
:label="buttonLabel('desc')"
:filled="orderBy === orderedDesc"
<os-button
variant="primary"
:appearance="orderBy === orderedDesc ? 'filled' : 'outline'"
size="sm"
:title="buttonTitle('desc')"
@click="toggleOrder(orderedDesc)"
data-test="newest-button"
size="small"
>
<template #icon>
<base-icon name="sort-amount-asc" />
</template>
{{ buttonLabel('desc') }}
</base-button>
</os-button>
</li>
<li class="item">
<base-button
icon="sort-amount-desc"
:label="buttonLabel('asc')"
:filled="orderBy === orderedAsc"
<os-button
variant="primary"
:appearance="orderBy === orderedAsc ? 'filled' : 'outline'"
size="sm"
:title="buttonTitle('asc')"
@click="toggleOrder(orderedAsc)"
data-test="oldest-button"
size="small"
>
<template #icon>
<base-icon name="sort-amount-desc" />
</template>
{{ buttonLabel('asc') }}
</base-button>
</os-button>
</li>
</template>
</filter-menu-section>
</template>
<script>
import { OsButton } from '@ocelot-social/ui'
import { mapGetters, mapMutations } from 'vuex'
import FilterMenuSection from '~/components/FilterMenu/FilterMenuSection'
@ -39,6 +44,7 @@ export default {
name: 'OrderByFilter',
components: {
FilterMenuSection,
OsButton,
},
computed: {
...mapGetters({

View File

@ -1,19 +1,28 @@
<template>
<div>
<nuxt-link to="/groups">
<base-button
icon="users"
<os-button
variant="primary"
appearance="ghost"
circle
ghost
:aria-label="$t('header.groups.tooltip')"
v-tooltip="{
content: $t('header.groups.tooltip'),
placement: 'bottom-start',
}"
/>
>
<template #icon>
<base-icon name="users" />
</template>
</os-button>
</nuxt-link>
</div>
</template>
<script>
export default {}
import { OsButton } from '@ocelot-social/ui'
export default {
components: { OsButton },
}
</script>

View File

@ -133,11 +133,17 @@
<!-- submit -->
<ds-space margin-top="large">
<nuxt-link to="/groups">
<os-button>{{ $t('actions.cancel') }}</os-button>
<os-button variant="default" appearance="filled">{{ $t('actions.cancel') }}</os-button>
</nuxt-link>
<ds-button type="submit" icon="save" primary :disabled="checkFormError(errors)" fill>
<os-button
variant="primary"
appearance="filled"
type="submit"
:disabled="checkFormError(errors)"
>
<template #icon><base-icon name="save" /></template>
{{ update ? $t('group.update') : $t('group.save') }}
</ds-button>
</os-button>
</ds-space>
</template>
</ds-form>

View File

@ -34,7 +34,7 @@ describe('HashtagsFilter.vue', () => {
describe('click clear search button', () => {
it('emits clearSearch', () => {
wrapper.find('.base-button').trigger('click')
wrapper.find('button[data-test="clear-search-button"]').trigger('click')
expect(wrapper.emitted().clearSearch).toHaveLength(1)
})
})

View File

@ -1,17 +1,27 @@
<template>
<base-card class="hashtags-filter">
<h2>{{ $t('hashtags-filter.hashtag-search', { hashtag }) }}</h2>
<base-button
icon="close"
<os-button
data-test="clear-search-button"
variant="primary"
appearance="ghost"
circle
:title="this.$t('hashtags-filter.clearSearch')"
:title="$t('hashtags-filter.clearSearch')"
:aria-label="$t('hashtags-filter.clearSearch')"
@click="clearSearch"
/>
>
<template #icon>
<base-icon name="close" />
</template>
</os-button>
</base-card>
</template>
<script>
import { OsButton } from '@ocelot-social/ui'
export default {
components: { OsButton },
props: {
hashtag: {
type: String,

View File

@ -139,12 +139,18 @@
</div>
</client-only>
<!-- hamburger menu -->
<base-button
icon="bars"
@click="toggleMobileMenuView"
<os-button
variant="primary"
:appearance="toggleMobileMenu ? 'filled' : 'outline'"
circle
class="hamburger-button"
/>
:aria-label="$t('site.navigation')"
@click="toggleMobileMenuView"
>
<template #icon>
<base-icon name="bars" />
</template>
</os-button>
</ds-flex-item>
</ds-flex>
<!-- search, filter -->
@ -274,6 +280,7 @@
</template>
<script>
import { OsButton } from '@ocelot-social/ui'
import { mapGetters } from 'vuex'
import isEmpty from 'lodash/isEmpty'
import { SHOW_GROUP_BUTTON_IN_HEADER } from '~/constants/groups.js'
@ -298,6 +305,7 @@ import GetCategories from '~/mixins/getCategoriesMixin.js'
export default {
mixins: [GetCategories],
components: {
OsButton,
AvatarMenu,
ChatNotificationMenu,
CustomButton,
@ -359,6 +367,9 @@ export default {
mounted() {
window.addEventListener('scroll', this.handleScroll)
},
beforeDestroy() {
window.removeEventListener('scroll', this.handleScroll)
},
}
</script>

View File

@ -1,16 +1,21 @@
<template>
<dropdown class="invite-button" offset="8" :placement="placement" noMouseLeaveClosing>
<template #default="{ toggleMenu }">
<base-button
icon="user-plus"
<os-button
variant="primary"
appearance="ghost"
circle
ghost
:aria-label="$t('invite-codes.button.tooltip')"
v-tooltip="{
content: $t('invite-codes.button.tooltip'),
placement: 'bottom-start',
}"
@click.prevent="toggleMenu"
/>
>
<template #icon>
<base-icon name="user-plus" />
</template>
</os-button>
</template>
<template #popover>
<div class="invite-list">
@ -31,6 +36,7 @@
</template>
<script>
import { OsButton } from '@ocelot-social/ui'
import Dropdown from '~/components/Dropdown'
import { mapGetters, mapMutations } from 'vuex'
import InvitationList from '~/components/_new/features/Invitations/InvitationList.vue'
@ -38,6 +44,7 @@ import { generatePersonalInviteCode, invalidateInviteCode } from '~/graphql/Invi
export default {
components: {
OsButton,
Dropdown,
InvitationList,
},
@ -66,7 +73,7 @@ export default {
},
update: (_, { data: { generatePersonalInviteCode } }) => {
this.setCurrentUser({
...this.currentUser,
...this.user,
inviteCodes: [...this.user.inviteCodes, generatePersonalInviteCode],
})
},
@ -85,7 +92,7 @@ export default {
},
update: (_, { data: { _invalidateInviteCode } }) => {
this.setCurrentUser({
...this.currentUser,
...this.user,
inviteCodes: this.user.inviteCodes.map((inviteCode) => ({
...inviteCode,
isValid: inviteCode.code === code ? false : inviteCode.isValid,
@ -103,10 +110,6 @@ export default {
</script>
<style lang="scss" scoped>
.invite-button {
color: $color-secondary;
}
.invite-list {
max-width: min(400px, 90vw);
padding: $space-small;

View File

@ -37,7 +37,7 @@ describe('LoginButton.vue', () => {
})
it('open popup', () => {
wrapper.find('.base-button').trigger('click')
wrapper.find('[data-test="login-btn"]').trigger('click')
expect(wrapper.find('.login-button').exists()).toBe(true)
})
})

View File

@ -1,7 +1,18 @@
<template>
<dropdown class="login-button" offset="8" :placement="placement">
<template #default="{ toggleMenu }">
<base-button icon="sign-in" circle ghost @click.prevent="toggleMenu" />
<os-button
data-test="login-btn"
variant="primary"
appearance="ghost"
circle
:aria-label="$t('login.login')"
@click.prevent="toggleMenu"
>
<template #icon>
<base-icon name="sign-in" />
</template>
</os-button>
</template>
<template #popover>
<div class="login-button-menu-popover">
@ -15,10 +26,12 @@
</template>
<script>
import { OsButton } from '@ocelot-social/ui'
import Dropdown from '~/components/Dropdown'
export default {
components: {
OsButton,
Dropdown,
},
props: {

View File

@ -36,9 +36,17 @@
<nuxt-link to="/password-reset/request">
{{ $t('login.forgotPassword') }}
</nuxt-link>
<base-button :loading="pending" filled name="submit" type="submit" icon="sign-in">
<os-button
:loading="pending"
variant="primary"
appearance="filled"
full-width
name="submit"
type="submit"
>
<template #icon><base-icon name="sign-in" /></template>
{{ $t('login.login') }}
</base-button>
</os-button>
<p>
{{ $t('login.no-account') }}
<nuxt-link to="/registration">{{ $t('login.register') }}</nuxt-link>
@ -58,12 +66,14 @@ import PageParamsLink from '~/components/_new/features/PageParamsLink/PageParams
import LocaleSwitch from '~/components/LocaleSwitch/LocaleSwitch'
import Logo from '~/components/Logo/Logo'
import ShowPassword from '../ShowPassword/ShowPassword.vue'
import { OsButton } from '@ocelot-social/ui'
import { mapGetters, mapMutations } from 'vuex'
export default {
components: {
LocaleSwitch,
Logo,
OsButton,
PageParamsLink,
ShowPassword,
},
@ -134,9 +144,7 @@ export default {
max-width: 620px;
margin: auto;
.base-button {
display: block;
width: 100%;
button[type='submit'] {
margin-top: $space-large;
margin-bottom: $space-small;
}

View File

@ -1,30 +1,41 @@
<template>
<div>
<nuxt-link to="/map">
<base-button
<os-button
class="map-button"
variant="primary"
appearance="ghost"
circle
ghost
:aria-label="$t('header.map.tooltip')"
v-tooltip="{
content: $t('header.map.tooltip'),
placement: 'bottom-start',
}"
>
<base-icon name="globe-detailed" size="large" />
</base-button>
<template #icon>
<base-icon name="globe-detailed" size="large" />
</template>
</os-button>
</nuxt-link>
</div>
</template>
<script>
import { OsButton } from '@ocelot-social/ui'
export default {
name: 'MapButton',
components: { OsButton },
}
</script>
<style lang="scss">
.map-button {
margin-left: 4px;
margin-right: 4px;
margin-left: 3px;
margin-right: 3px;
.base-icon > .svg.--large {
margin-left: 0;
}
}
</style>

View File

@ -10,36 +10,44 @@
<p v-html="message" />
<template #footer>
<base-button
<os-button
class="cancel"
:danger="!modalData.buttons.confirm.danger"
:icon="modalData.buttons.cancel.icon"
:variant="!modalData.buttons.confirm.danger ? 'danger' : 'primary'"
appearance="outline"
@click="cancel"
data-test="cancel-button"
>
<template v-if="modalData.buttons.cancel.icon" #icon>
<base-icon :name="modalData.buttons.cancel.icon" />
</template>
{{ $t(modalData.buttons.cancel.textIdent) }}
</base-button>
</os-button>
<base-button
:danger="modalData.buttons.confirm.danger"
<os-button
class="confirm"
:icon="modalData.buttons.confirm.icon"
:variant="modalData.buttons.confirm.danger ? 'danger' : 'primary'"
appearance="filled"
:loading="loading"
@click="confirm"
data-test="confirm-button"
>
<template v-if="modalData.buttons.confirm.icon" #icon>
<base-icon :name="modalData.buttons.confirm.icon" />
</template>
{{ $t(modalData.buttons.confirm.textIdent) }}
</base-button>
</os-button>
</template>
</ds-modal>
</template>
<script>
import { OsButton } from '@ocelot-social/ui'
import { SweetalertIcon } from 'vue-sweetalert-icons'
export default {
name: 'ConfirmModal',
components: {
OsButton,
SweetalertIcon,
},
emits: ['close'],

View File

@ -40,9 +40,16 @@
<os-button variant="primary" appearance="outline" class="cancel" @click="cancel">
{{ $t('actions.cancel') }}
</os-button>
<base-button danger filled class="confirm" icon="exclamation-circle" @click="openModal">
<os-button
variant="danger"
appearance="filled"
class="confirm"
:loading="loading"
@click="openModal"
>
<template #icon><base-icon name="exclamation-circle" /></template>
{{ $t('settings.deleteUserAccount.name') }}
</base-button>
</os-button>
</template>
</ds-modal>
</template>
@ -162,10 +169,10 @@ export default {
</script>
<style lang="scss">
.ds-modal {
.delete-user-modal.ds-modal {
max-width: 700px !important;
}
.hc-modal-success {
.delete-user-modal .hc-modal-success {
pointer-events: none;
position: absolute;
width: 100%;
@ -177,7 +184,7 @@ export default {
z-index: $z-index-modal;
border-radius: $border-radius-x-large;
}
.bold {
.delete-user-modal .bold {
font-weight: 700;
}
</style>

View File

@ -7,9 +7,16 @@
<os-button variant="primary" appearance="outline" class="cancel" @click="cancel">
{{ $t('disable.cancel') }}
</os-button>
<base-button danger filled class="confirm" icon="exclamation-circle" @click="confirm">
<os-button
variant="danger"
appearance="filled"
class="confirm"
:loading="loading"
@click="confirm"
>
<template #icon><base-icon name="exclamation-circle" /></template>
{{ $t('disable.submit') }}
</base-button>
</os-button>
</template>
</ds-modal>
</template>
@ -52,6 +59,7 @@ export default {
}, 1000)
},
async confirm() {
this.loading = true
try {
// TODO: Use the "modalData" structure introduced in "ConfirmModal" and refactor this here. Be aware that all the Jest tests have to be refactored as well !!!
// await this.modalData.buttons.confirm.callback()
@ -73,6 +81,8 @@ export default {
} catch (err) {
this.$toast.error(err.message)
this.isOpen = false
} finally {
this.loading = false
}
},
},

View File

@ -1,5 +1,5 @@
<template>
<ds-modal :title="title" :is-open="isOpen" @cancel="cancel">
<ds-modal class="report-modal" :title="title" :is-open="isOpen" @cancel="cancel">
<transition name="ds-transition-fade">
<ds-flex v-if="success" class="hc-modal-success" centered>
<sweetalert-icon icon="success" />
@ -29,26 +29,32 @@
</small>
<ds-space />
<template #footer>
<base-button class="cancel" icon="close" @click="cancel">
<os-button class="cancel" variant="primary" appearance="outline" @click="cancel">
<template #icon>
<base-icon name="close" />
</template>
{{ $t('report.cancel') }}
</base-button>
</os-button>
<base-button
danger
filled
<os-button
class="confirm"
icon="exclamation-circle"
variant="danger"
appearance="filled"
:disabled="!form.reasonCategory"
:loading="loading"
@click="confirm"
>
<template #icon>
<base-icon name="exclamation-circle" />
</template>
{{ $t('report.submit') }}
</base-button>
</os-button>
</template>
</ds-modal>
</template>
<script>
import { OsButton } from '@ocelot-social/ui'
import { SweetalertIcon } from 'vue-sweetalert-icons'
import { reportMutation } from '~/graphql/Moderation.js'
import { valuesReasonCategoryOptions } from '~/constants/modals.js'
@ -57,6 +63,7 @@ import validReport from '~/components/utils/ReportModal'
export default {
name: 'ReportModal',
components: {
OsButton,
SweetalertIcon,
},
props: {
@ -159,26 +166,27 @@ export default {
</script>
<style lang="scss">
.ds-modal {
max-width: 600px !important;
.report-modal.ds-modal {
width: 700px !important;
max-width: 700px !important;
}
.ds-radio-option {
.report-modal .ds-radio-option {
width: 100% !important;
}
.ds-radio-option-label {
.report-modal .ds-radio-option-label {
margin: 5px 20px 5px 5px !important;
width: 100% !important;
}
.reason-description {
.report-modal .reason-description {
margin-top: $space-x-small !important;
margin-bottom: $space-xx-small !important;
}
.smallTag {
.report-modal .smallTag {
width: 100%;
position: relative;
left: 90%;
}
.hc-modal-success {
.report-modal .hc-modal-success {
pointer-events: none;
position: absolute;
width: 100%;

View File

@ -4,27 +4,36 @@
class="notifications-menu"
:to="{ name: 'notifications' }"
>
<base-button
icon="bell"
ghost
circle
v-tooltip="{
content: $t('header.notifications.tooltip'),
placement: 'bottom-start',
}"
/>
</nuxt-link>
<nuxt-link v-else-if="noMenu" class="notifications-menu" :to="{ name: 'notifications' }">
<base-button
ghost
<os-button
variant="primary"
appearance="ghost"
circle
:aria-label="$t('header.notifications.tooltip')"
v-tooltip="{
content: $t('header.notifications.tooltip'),
placement: 'bottom-start',
}"
>
<counter-icon icon="bell" :count="unreadNotificationsCount" danger />
</base-button>
<template #icon>
<base-icon name="bell" />
</template>
</os-button>
</nuxt-link>
<nuxt-link v-else-if="noMenu" class="notifications-menu" :to="{ name: 'notifications' }">
<os-button
variant="primary"
appearance="ghost"
circle
:aria-label="$t('header.notifications.tooltip')"
v-tooltip="{
content: $t('header.notifications.tooltip'),
placement: 'bottom-start',
}"
>
<template #icon>
<counter-icon icon="bell" :count="unreadNotificationsCount" danger />
</template>
</os-button>
</nuxt-link>
<dropdown
v-else
@ -35,17 +44,21 @@
ref="dropdown"
>
<template #default="{ toggleMenu }">
<base-button
ghost
<os-button
variant="primary"
appearance="ghost"
circle
:aria-label="$t('header.notifications.tooltip')"
v-tooltip="{
content: $t('header.notifications.tooltip'),
placement: 'bottom-start',
}"
@click="toggleMenu"
>
<counter-icon icon="bell" :count="unreadNotificationsCount" danger />
</base-button>
<template #icon>
<counter-icon icon="bell" :count="unreadNotificationsCount" danger />
</template>
</os-button>
</template>
<template #popover="{ closeMenu }">
<ds-flex class="notifications-link-container">

View File

@ -41,10 +41,6 @@ describe('ChangePassword.vue', () => {
})
describe('validations', () => {
it('invalid', () => {
expect(wrapper.vm.disabled).toBe(true)
})
describe('old password and new password', () => {
describe('match', () => {
beforeEach(() => {
@ -52,10 +48,6 @@ describe('ChangePassword.vue', () => {
wrapper.find('input#password').setValue('some secret')
})
it('invalid', () => {
expect(wrapper.vm.disabled).toBe(true)
})
it.skip('displays a warning', () => {
const calls = mocks.validate.mock.calls
const expected = [['change-password.validations.old-and-new-password-match']]

View File

@ -24,15 +24,22 @@
/>
<password-strength :password="formData.password" />
<ds-space margin-top="base">
<base-button :loading="loading" :disabled="errors" filled type="submit">
<os-button
variant="primary"
appearance="filled"
:loading="loading"
:disabled="!!errors"
type="submit"
>
{{ $t('settings.security.change-password.button') }}
</base-button>
</os-button>
</ds-space>
</template>
</ds-form>
</template>
<script>
import { OsButton } from '@ocelot-social/ui'
import gql from 'graphql-tag'
import PasswordStrength from './Strength'
import PasswordForm from '~/components/utils/PasswordFormHelper'
@ -40,6 +47,7 @@ import PasswordForm from '~/components/utils/PasswordFormHelper'
export default {
name: 'ChangePassword',
components: {
OsButton,
PasswordStrength,
},
data() {
@ -58,7 +66,6 @@ export default {
...passwordForm.formSchema,
},
loading: false,
disabled: true,
}
},
methods: {

View File

@ -24,9 +24,15 @@
/>
<password-strength :password="formData.password" />
<ds-space margin-top="base" margin-bottom="xxx-small">
<base-button :loading="$apollo.loading" :disabled="errors" filled type="submit">
<os-button
variant="primary"
appearance="filled"
:loading="$apollo.loading"
:disabled="!!errors"
type="submit"
>
{{ $t('settings.security.change-password.button') }}
</base-button>
</os-button>
</ds-space>
</template>
</ds-form>
@ -61,6 +67,7 @@
</template>
<script>
import { OsButton } from '@ocelot-social/ui'
import emails from '../../constants/emails.js'
import PasswordStrength from '../Password/Strength'
import gql from 'graphql-tag'
@ -69,6 +76,7 @@ import PasswordForm from '~/components/utils/PasswordFormHelper'
export default {
components: {
OsButton,
SweetalertIcon,
PasswordStrength,
},

View File

@ -20,16 +20,15 @@
<ds-space margin-botton="large">
<ds-text align="left">{{ $t('components.password-reset.request.form.description') }}</ds-text>
</ds-space>
<base-button
<os-button
variant="primary"
appearance="filled"
:disabled="disabled"
:loading="$apollo.loading"
filled
padding
name="submit"
type="submit"
>
{{ $t('components.password-reset.request.form.submit') }}
</base-button>
</os-button>
<slot></slot>
</ds-form>
<div v-else>
@ -43,11 +42,13 @@
</template>
<script>
import { OsButton } from '@ocelot-social/ui'
import gql from 'graphql-tag'
import { SweetalertIcon } from 'vue-sweetalert-icons'
export default {
components: {
OsButton,
SweetalertIcon,
},
data() {

View File

@ -30,15 +30,15 @@
model="email"
name="email"
/>
<base-button
<os-button
variant="primary"
appearance="filled"
:disabled="disabled"
:loading="$apollo.loading"
filled
name="submit"
type="submit"
>
{{ $t('components.registration.signup.form.submit') }}
</base-button>
</os-button>
<slot></slot>
</ds-form>
</ds-space>
@ -62,6 +62,7 @@
</template>
<script>
import { OsButton } from '@ocelot-social/ui'
import gql from 'graphql-tag'
import metadata from '~/constants/metadata'
import { SweetalertIcon } from 'vue-sweetalert-icons'
@ -77,6 +78,7 @@ export const SignupMutation = gql`
export default {
name: 'Signup',
components: {
OsButton,
SweetalertIcon,
},
props: {

View File

@ -7,9 +7,18 @@
<os-button variant="primary" appearance="outline" class="cancel" @click="cancel">
{{ $t('release.cancel') }}
</os-button>
<base-button danger filled class="confirm" icon="exclamation-circle" @click="confirm">
<os-button
variant="danger"
appearance="filled"
class="confirm"
:loading="loading"
@click="confirm"
>
<template #icon>
<base-icon name="exclamation-circle" />
</template>
{{ $t('release.submit') }}
</base-button>
</os-button>
</template>
</ds-modal>
</template>
@ -52,6 +61,7 @@ export default {
}, 1000)
},
async confirm() {
this.loading = true
try {
// TODO: Use the "modalData" structure introduced in "ConfirmModal" and refactor this here. Be aware that all the Jest tests have to be refactored as well !!!
// await this.modalData.buttons.confirm.callback()
@ -73,6 +83,11 @@ export default {
} catch (err) {
this.$toast.error(err.message)
this.isOpen = false
setTimeout(() => {
this.$emit('close')
}, 1000)
} finally {
this.loading = false
}
},
},

View File

@ -49,7 +49,7 @@ describe('LocationSelect', () => {
})
it('renders the clearLocationName button by default', () => {
expect(wrapper.find('.base-button').exists()).toBe(true)
expect(wrapper.find('button[data-test="clear-location-button"]').exists()).toBe(true)
})
it('calls apollo with given value', () => {
@ -64,7 +64,7 @@ describe('LocationSelect', () => {
describe('clearLocationName button click', () => {
beforeEach(() => {
wrapper.find('.base-button').trigger('click')
wrapper.find('button[data-test="clear-location-button"]').trigger('click')
})
it('emits an empty string', () => {
@ -81,7 +81,7 @@ describe('LocationSelect', () => {
})
it('does not show clear location name button', () => {
expect(wrapper.find('.base-button').exists()).toBe(false)
expect(wrapper.find('button[data-test="clear-location-button"]').exists()).toBe(false)
})
})

View File

@ -13,24 +13,30 @@
:loading="loadingGeo"
@input.native="handleCityInput"
/>
<base-button
<os-button
v-if="locationName !== '' && canBeCleared"
icon="close"
ghost
size="small"
style="position: relative; display: inline-block; right: -94%; top: -48px; width: 29px"
@click.native="clearLocationName"
></base-button>
data-test="clear-location-button"
variant="primary"
appearance="ghost"
size="sm"
:aria-label="$t('actions.clear')"
style="right: -94%; top: -48px"
@click="clearLocationName"
>
<template #icon><base-icon name="close" /></template>
</os-button>
</div>
</template>
<script>
import { OsButton } from '@ocelot-social/ui'
import { queryLocations } from '~/graphql/location'
let timeout
export default {
name: 'LocationSelect',
components: { OsButton },
props: {
value: {
required: true,
@ -128,9 +134,8 @@ export default {
this.loadingGeo = false
}
},
clearLocationName(event) {
event.target.value = ''
this.$emit('input', event.target.value)
clearLocationName() {
this.currentValue = ''
},
},
}

View File

@ -14,16 +14,20 @@
</div>
</vue-dropzone>
<div v-show="!showCropper && hasImage">
<base-button
<os-button
class="delete-image-button"
icon="trash"
variant="danger"
appearance="filled"
circle
danger
filled
data-test="delete-button"
:title="$t('actions.delete')"
:aria-label="$t('actions.delete')"
@click.stop="deleteImage"
/>
>
<template #icon>
<base-icon name="trash" />
</template>
</os-button>
</div>
<div v-show="!showCropper && imageCanBeCropped" class="crop-overlay">
<os-button class="crop-confirm" variant="primary" @click="initCropper">
@ -35,15 +39,20 @@
<os-button class="crop-confirm" variant="primary" @click="cropImage">
{{ $t('contribution.teaserImage.cropperConfirm') }}
</os-button>
<base-button
<os-button
class="crop-cancel"
icon="close"
size="small"
variant="danger"
appearance="filled"
circle
danger
filled
size="sm"
:title="$t('actions.cancel')"
:aria-label="$t('actions.cancel')"
@click="closeCropper"
/>
>
<template #icon>
<base-icon name="close" />
</template>
</os-button>
</div>
</div>
</template>
@ -206,17 +215,17 @@ export default {
}
> .crop-cancel {
position: absolute;
right: $space-x-small;
top: $space-x-small;
position: absolute !important;
right: $space-x-small !important;
top: $space-x-small !important;
z-index: $z-index-surface;
}
}
.delete-image-button {
position: absolute;
top: $space-small;
right: $space-small;
position: absolute !important;
top: $space-small !important;
right: $space-small !important;
z-index: $z-index-surface;
cursor: pointer;
}
@ -247,13 +256,6 @@ export default {
opacity: $opacity-soft;
}
> .base-button {
position: absolute;
top: $space-small;
right: $space-small;
z-index: $z-index-surface;
}
> .supported-formats {
margin-top: 150px;
font-weight: bold;

View File

@ -7,18 +7,25 @@ exports[`ActionButton.vue when disabled renders 1`] = `
>
<button
aria-label="Click me"
class="base-button --icon-only --circle"
class="inline-flex items-center justify-center [white-space-collapse:collapse] relative appearance-none font-semibold tracking-[0.05em] transition-[color,background-color] duration-[80ms] ease-[cubic-bezier(0.25,0.46,0.45,0.94)] cursor-pointer select-none border-[0.8px] border-solid focus:outline-1 disabled:pointer-events-none disabled:cursor-default focus:outline-dashed focus:outline-[var(--color-primary)] bg-transparent shadow-none disabled:border-[var(--color-disabled)] disabled:text-[var(--color-disabled)] h-[36px] min-w-[36px] text-[15px] leading-[normal] align-middle border-[var(--color-primary)] text-[var(--color-primary)] hover:bg-[var(--color-primary)] hover:text-[var(--color-primary-contrast)] active:bg-[var(--color-primary-active)] active:border-[var(--color-primary-active)] active:text-[var(--color-primary-contrast)] rounded-full p-0 w-[36px]"
data-appearance="outline"
data-variant="primary"
disabled="disabled"
type="button"
>
<span
class="base-icon"
class="inline-flex items-center"
>
<!---->
<span
class="os-button__icon inline-flex items-center shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current"
>
<span
class="base-icon"
>
<!---->
</span>
</span>
</span>
<!---->
</button>
<div
@ -37,17 +44,24 @@ exports[`ActionButton.vue when not disabled renders 1`] = `
>
<button
aria-label="Click me"
class="base-button --icon-only --circle"
class="inline-flex items-center justify-center [white-space-collapse:collapse] relative appearance-none font-semibold tracking-[0.05em] transition-[color,background-color] duration-[80ms] ease-[cubic-bezier(0.25,0.46,0.45,0.94)] cursor-pointer select-none border-[0.8px] border-solid focus:outline-1 disabled:pointer-events-none disabled:cursor-default focus:outline-dashed focus:outline-[var(--color-primary)] bg-transparent shadow-none disabled:border-[var(--color-disabled)] disabled:text-[var(--color-disabled)] h-[36px] min-w-[36px] text-[15px] leading-[normal] align-middle border-[var(--color-primary)] text-[var(--color-primary)] hover:bg-[var(--color-primary)] hover:text-[var(--color-primary-contrast)] active:bg-[var(--color-primary-active)] active:border-[var(--color-primary-active)] active:text-[var(--color-primary-contrast)] rounded-full p-0 w-[36px]"
data-appearance="outline"
data-variant="primary"
type="button"
>
<span
class="base-icon"
class="inline-flex items-center"
>
<!---->
<span
class="os-button__icon inline-flex items-center shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current"
>
<span
class="base-icon"
>
<!---->
</span>
</span>
</span>
<!---->
</button>
<div

View File

@ -8,17 +8,24 @@ exports[`ObserveButton observed renders 1`] = `
>
<button
aria-label="observeButton.observed"
class="base-button --icon-only --circle --filled"
class="inline-flex items-center justify-center [white-space-collapse:collapse] relative appearance-none font-semibold tracking-[0.05em] transition-[color,background-color] duration-[80ms] ease-[cubic-bezier(0.25,0.46,0.45,0.94)] cursor-pointer select-none border-[0.8px] border-solid focus:outline-1 disabled:pointer-events-none disabled:cursor-default focus:outline-dashed focus:outline-[var(--color-primary)] shadow-[inset_0_0_0_1px_rgba(0,0,0,0.05)] disabled:bg-[var(--color-disabled)] disabled:text-[var(--color-disabled-contrast)] disabled:border-[var(--color-disabled)] disabled:shadow-[inset_0_0_0_1px_transparent] h-[36px] min-w-[36px] text-[15px] leading-[normal] align-middle bg-[var(--color-primary)] text-[var(--color-primary-contrast)] border-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] hover:border-[var(--color-primary-hover)] hover:text-[var(--color-primary-contrast)] active:bg-[var(--color-primary-active)] active:border-[var(--color-primary-active)] active:text-[var(--color-primary-contrast)] rounded-full p-0 w-[36px]"
data-appearance="filled"
data-variant="primary"
type="button"
>
<span
class="base-icon"
class="inline-flex items-center"
>
<!---->
<span
class="os-button__icon inline-flex items-center shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current"
>
<span
class="base-icon"
>
<!---->
</span>
</span>
</span>
<!---->
</button>
<div
@ -38,17 +45,24 @@ exports[`ObserveButton unobserved renders 1`] = `
>
<button
aria-label="observeButton.observed"
class="base-button --icon-only --circle"
class="inline-flex items-center justify-center [white-space-collapse:collapse] relative appearance-none font-semibold tracking-[0.05em] transition-[color,background-color] duration-[80ms] ease-[cubic-bezier(0.25,0.46,0.45,0.94)] cursor-pointer select-none border-[0.8px] border-solid focus:outline-1 disabled:pointer-events-none disabled:cursor-default focus:outline-dashed focus:outline-[var(--color-primary)] bg-transparent shadow-none disabled:border-[var(--color-disabled)] disabled:text-[var(--color-disabled)] h-[36px] min-w-[36px] text-[15px] leading-[normal] align-middle border-[var(--color-primary)] text-[var(--color-primary)] hover:bg-[var(--color-primary)] hover:text-[var(--color-primary-contrast)] active:bg-[var(--color-primary-active)] active:border-[var(--color-primary-active)] active:text-[var(--color-primary-contrast)] rounded-full p-0 w-[36px]"
data-appearance="outline"
data-variant="primary"
type="button"
>
<span
class="base-icon"
class="inline-flex items-center"
>
<!---->
<span
class="os-button__icon inline-flex items-center shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current"
>
<span
class="base-icon"
>
<!---->
</span>
</span>
</span>
<!---->
</button>
<div

View File

@ -7,17 +7,24 @@ exports[`ShoutButton.vue renders button and text 1`] = `
>
<button
aria-label="shoutButton.shouted"
class="base-button --icon-only --circle"
class="inline-flex items-center justify-center [white-space-collapse:collapse] relative appearance-none font-semibold tracking-[0.05em] transition-[color,background-color] duration-[80ms] ease-[cubic-bezier(0.25,0.46,0.45,0.94)] cursor-pointer select-none border-[0.8px] border-solid focus:outline-1 disabled:pointer-events-none disabled:cursor-default focus:outline-dashed focus:outline-[var(--color-primary)] bg-transparent shadow-none disabled:border-[var(--color-disabled)] disabled:text-[var(--color-disabled)] h-[36px] min-w-[36px] text-[15px] leading-[normal] align-middle border-[var(--color-primary)] text-[var(--color-primary)] hover:bg-[var(--color-primary)] hover:text-[var(--color-primary-contrast)] active:bg-[var(--color-primary-active)] active:border-[var(--color-primary-active)] active:text-[var(--color-primary-contrast)] rounded-full p-0 w-[36px]"
data-appearance="outline"
data-variant="primary"
type="button"
>
<span
class="base-icon"
class="inline-flex items-center"
>
<!---->
<span
class="os-button__icon inline-flex items-center shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current"
>
<span
class="base-icon"
>
<!---->
</span>
</span>
</span>
<!---->
</button>
<div
@ -36,17 +43,24 @@ exports[`ShoutButton.vue toggle the button 1`] = `
>
<button
aria-label="shoutButton.shouted"
class="base-button --icon-only --circle --filled"
class="inline-flex items-center justify-center [white-space-collapse:collapse] relative appearance-none font-semibold tracking-[0.05em] transition-[color,background-color] duration-[80ms] ease-[cubic-bezier(0.25,0.46,0.45,0.94)] cursor-pointer select-none border-[0.8px] border-solid focus:outline-1 disabled:pointer-events-none disabled:cursor-default focus:outline-dashed focus:outline-[var(--color-primary)] shadow-[inset_0_0_0_1px_rgba(0,0,0,0.05)] disabled:bg-[var(--color-disabled)] disabled:text-[var(--color-disabled-contrast)] disabled:border-[var(--color-disabled)] disabled:shadow-[inset_0_0_0_1px_transparent] h-[36px] min-w-[36px] text-[15px] leading-[normal] align-middle bg-[var(--color-primary)] text-[var(--color-primary-contrast)] border-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] hover:border-[var(--color-primary-hover)] hover:text-[var(--color-primary-contrast)] active:bg-[var(--color-primary-active)] active:border-[var(--color-primary-active)] active:text-[var(--color-primary-contrast)] rounded-full p-0 w-[36px]"
data-appearance="filled"
data-variant="primary"
type="button"
>
<span
class="base-icon"
class="inline-flex items-center"
>
<!---->
<span
class="os-button__icon inline-flex items-center shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current"
>
<span
class="base-icon"
>
<!---->
</span>
</span>
</span>
<!---->
</button>
<div
@ -65,17 +79,24 @@ exports[`ShoutButton.vue toggle the button, but backend fails 1`] = `
>
<button
aria-label="shoutButton.shouted"
class="base-button --icon-only --circle --filled"
class="inline-flex items-center justify-center [white-space-collapse:collapse] relative appearance-none font-semibold tracking-[0.05em] transition-[color,background-color] duration-[80ms] ease-[cubic-bezier(0.25,0.46,0.45,0.94)] cursor-pointer select-none border-[0.8px] border-solid focus:outline-1 disabled:pointer-events-none disabled:cursor-default focus:outline-dashed focus:outline-[var(--color-primary)] shadow-[inset_0_0_0_1px_rgba(0,0,0,0.05)] disabled:bg-[var(--color-disabled)] disabled:text-[var(--color-disabled-contrast)] disabled:border-[var(--color-disabled)] disabled:shadow-[inset_0_0_0_1px_transparent] h-[36px] min-w-[36px] text-[15px] leading-[normal] align-middle bg-[var(--color-primary)] text-[var(--color-primary-contrast)] border-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] hover:border-[var(--color-primary-hover)] hover:text-[var(--color-primary-contrast)] active:bg-[var(--color-primary-active)] active:border-[var(--color-primary-active)] active:text-[var(--color-primary-contrast)] rounded-full p-0 w-[36px]"
data-appearance="filled"
data-variant="primary"
type="button"
>
<span
class="base-icon"
class="inline-flex items-center"
>
<!---->
<span
class="os-button__icon inline-flex items-center shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current"
>
<span
class="base-icon"
>
<!---->
</span>
</span>
</span>
<!---->
</button>
<div
@ -94,17 +115,24 @@ exports[`ShoutButton.vue when shouted renders 1`] = `
>
<button
aria-label="shoutButton.shouted"
class="base-button --icon-only --circle --filled"
class="inline-flex items-center justify-center [white-space-collapse:collapse] relative appearance-none font-semibold tracking-[0.05em] transition-[color,background-color] duration-[80ms] ease-[cubic-bezier(0.25,0.46,0.45,0.94)] cursor-pointer select-none border-[0.8px] border-solid focus:outline-1 disabled:pointer-events-none disabled:cursor-default focus:outline-dashed focus:outline-[var(--color-primary)] shadow-[inset_0_0_0_1px_rgba(0,0,0,0.05)] disabled:bg-[var(--color-disabled)] disabled:text-[var(--color-disabled-contrast)] disabled:border-[var(--color-disabled)] disabled:shadow-[inset_0_0_0_1px_transparent] h-[36px] min-w-[36px] text-[15px] leading-[normal] align-middle bg-[var(--color-primary)] text-[var(--color-primary-contrast)] border-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] hover:border-[var(--color-primary-hover)] hover:text-[var(--color-primary-contrast)] active:bg-[var(--color-primary-active)] active:border-[var(--color-primary-active)] active:text-[var(--color-primary-contrast)] rounded-full p-0 w-[36px]"
data-appearance="filled"
data-variant="primary"
type="button"
>
<span
class="base-icon"
class="inline-flex items-center"
>
<!---->
<span
class="os-button__icon inline-flex items-center shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current"
>
<span
class="base-icon"
>
<!---->
</span>
</span>
</span>
<!---->
</button>
<div

View File

@ -8,21 +8,29 @@
v-model="comment"
:schema="{ type: 'string', max: 30 }"
/>
<base-button
<os-button
variant="primary"
appearance="outline"
circle
class="generate-invite-code"
:aria-label="$t('invite-codes.generate-code')"
icon="plus"
type="submit"
:disabled="disabled"
/>
>
<template #icon>
<base-icon name="plus" />
</template>
</os-button>
</form>
</div>
</template>
<script>
import { OsButton } from '@ocelot-social/ui'
export default {
name: 'CreateInvitation',
components: { OsButton },
props: {
disabled: {
type: Boolean,

View File

@ -16,33 +16,43 @@
</div>
</div>
<div class="actions">
<base-button
<os-button
variant="primary"
appearance="outline"
circle
class="copy-button"
icon="copy"
@click="copyInviteCode(inviteCode.copy)"
@click="copyInviteCode"
:disabled="!canCopy"
:aria-label="$t('invite-codes.copy-code')"
/>
<base-button
>
<template #icon>
<base-icon name="copy" />
</template>
</os-button>
<os-button
variant="primary"
appearance="outline"
circle
class="invalidate-button"
icon="trash"
@click="openDeleteModal"
:aria-label="$t('invite-codes.invalidate')"
/>
>
<template #icon>
<base-icon name="trash" />
</template>
</os-button>
</div>
</li>
</template>
<script>
import { OsButton } from '@ocelot-social/ui'
import { mapMutations } from 'vuex'
import BaseButton from '~/components/_new/generic/BaseButton/BaseButton.vue'
export default {
name: 'Invitation',
components: {
BaseButton,
OsButton,
},
props: {
inviteCode: {

View File

@ -52,17 +52,24 @@ exports[`CreateInvitation.vue renders 1`] = `
<button
aria-label="invite-codes.generate-code"
class="generate-invite-code base-button --icon-only --circle"
class="generate-invite-code inline-flex items-center justify-center [white-space-collapse:collapse] relative appearance-none font-semibold tracking-[0.05em] transition-[color,background-color] duration-[80ms] ease-[cubic-bezier(0.25,0.46,0.45,0.94)] cursor-pointer select-none border-[0.8px] border-solid focus:outline-1 disabled:pointer-events-none disabled:cursor-default focus:outline-dashed focus:outline-[var(--color-primary)] bg-transparent shadow-none disabled:border-[var(--color-disabled)] disabled:text-[var(--color-disabled)] h-[36px] min-w-[36px] text-[15px] leading-[normal] align-middle border-[var(--color-primary)] text-[var(--color-primary)] hover:bg-[var(--color-primary)] hover:text-[var(--color-primary-contrast)] active:bg-[var(--color-primary-active)] active:border-[var(--color-primary-active)] active:text-[var(--color-primary-contrast)] rounded-full p-0 w-[36px] generate-invite-code"
data-appearance="outline"
data-variant="primary"
type="submit"
>
<span
class="base-icon"
class="inline-flex items-center"
>
<!---->
<span
class="os-button__icon inline-flex items-center shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current"
>
<span
class="base-icon"
>
<!---->
</span>
</span>
</span>
<!---->
</button>
</form>
</div>
@ -122,17 +129,24 @@ exports[`CreateInvitation.vue renders with disabled button 1`] = `
<button
aria-label="invite-codes.generate-code"
class="generate-invite-code base-button --icon-only --circle"
class="generate-invite-code inline-flex items-center justify-center [white-space-collapse:collapse] relative appearance-none font-semibold tracking-[0.05em] transition-[color,background-color] duration-[80ms] ease-[cubic-bezier(0.25,0.46,0.45,0.94)] cursor-pointer select-none border-[0.8px] border-solid focus:outline-1 disabled:pointer-events-none disabled:cursor-default focus:outline-dashed focus:outline-[var(--color-primary)] bg-transparent shadow-none disabled:border-[var(--color-disabled)] disabled:text-[var(--color-disabled)] h-[36px] min-w-[36px] text-[15px] leading-[normal] align-middle border-[var(--color-primary)] text-[var(--color-primary)] hover:bg-[var(--color-primary)] hover:text-[var(--color-primary-contrast)] active:bg-[var(--color-primary-active)] active:border-[var(--color-primary-active)] active:text-[var(--color-primary-contrast)] rounded-full p-0 w-[36px] generate-invite-code"
data-appearance="outline"
data-variant="primary"
type="submit"
>
<span
class="base-icon"
class="inline-flex items-center"
>
<!---->
<span
class="os-button__icon inline-flex items-center shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current"
>
<span
class="base-icon"
>
<!---->
</span>
</span>
</span>
<!---->
</button>
</form>
</div>

View File

@ -41,32 +41,46 @@ exports[`Invitation.vue when the invite code was not redeemed renders 1`] = `
>
<button
aria-label="invite-codes.copy-code"
class="copy-button base-button --icon-only --circle"
class="copy-button inline-flex items-center justify-center [white-space-collapse:collapse] relative appearance-none font-semibold tracking-[0.05em] transition-[color,background-color] duration-[80ms] ease-[cubic-bezier(0.25,0.46,0.45,0.94)] cursor-pointer select-none border-[0.8px] border-solid focus:outline-1 disabled:pointer-events-none disabled:cursor-default focus:outline-dashed focus:outline-[var(--color-primary)] bg-transparent shadow-none disabled:border-[var(--color-disabled)] disabled:text-[var(--color-disabled)] h-[36px] min-w-[36px] text-[15px] leading-[normal] align-middle border-[var(--color-primary)] text-[var(--color-primary)] hover:bg-[var(--color-primary)] hover:text-[var(--color-primary-contrast)] active:bg-[var(--color-primary-active)] active:border-[var(--color-primary-active)] active:text-[var(--color-primary-contrast)] rounded-full p-0 w-[36px] copy-button"
data-appearance="outline"
data-variant="primary"
type="button"
>
<span
class="base-icon"
class="inline-flex items-center"
>
<!---->
<span
class="os-button__icon inline-flex items-center shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current"
>
<span
class="base-icon"
>
<!---->
</span>
</span>
</span>
<!---->
</button>
<button
aria-label="invite-codes.invalidate"
class="invalidate-button base-button --icon-only --circle"
class="invalidate-button inline-flex items-center justify-center [white-space-collapse:collapse] relative appearance-none font-semibold tracking-[0.05em] transition-[color,background-color] duration-[80ms] ease-[cubic-bezier(0.25,0.46,0.45,0.94)] cursor-pointer select-none border-[0.8px] border-solid focus:outline-1 disabled:pointer-events-none disabled:cursor-default focus:outline-dashed focus:outline-[var(--color-primary)] bg-transparent shadow-none disabled:border-[var(--color-disabled)] disabled:text-[var(--color-disabled)] h-[36px] min-w-[36px] text-[15px] leading-[normal] align-middle border-[var(--color-primary)] text-[var(--color-primary)] hover:bg-[var(--color-primary)] hover:text-[var(--color-primary-contrast)] active:bg-[var(--color-primary-active)] active:border-[var(--color-primary-active)] active:text-[var(--color-primary-contrast)] rounded-full p-0 w-[36px] invalidate-button"
data-appearance="outline"
data-variant="primary"
type="button"
>
<span
class="base-icon"
class="inline-flex items-center"
>
<!---->
<span
class="os-button__icon inline-flex items-center shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current"
>
<span
class="base-icon"
>
<!---->
</span>
</span>
</span>
<!---->
</button>
</div>
</li>
@ -114,32 +128,46 @@ exports[`Invitation.vue when the invite code was redeemed renders 1`] = `
>
<button
aria-label="invite-codes.copy-code"
class="copy-button base-button --icon-only --circle"
class="copy-button inline-flex items-center justify-center [white-space-collapse:collapse] relative appearance-none font-semibold tracking-[0.05em] transition-[color,background-color] duration-[80ms] ease-[cubic-bezier(0.25,0.46,0.45,0.94)] cursor-pointer select-none border-[0.8px] border-solid focus:outline-1 disabled:pointer-events-none disabled:cursor-default focus:outline-dashed focus:outline-[var(--color-primary)] bg-transparent shadow-none disabled:border-[var(--color-disabled)] disabled:text-[var(--color-disabled)] h-[36px] min-w-[36px] text-[15px] leading-[normal] align-middle border-[var(--color-primary)] text-[var(--color-primary)] hover:bg-[var(--color-primary)] hover:text-[var(--color-primary-contrast)] active:bg-[var(--color-primary-active)] active:border-[var(--color-primary-active)] active:text-[var(--color-primary-contrast)] rounded-full p-0 w-[36px] copy-button"
data-appearance="outline"
data-variant="primary"
type="button"
>
<span
class="base-icon"
class="inline-flex items-center"
>
<!---->
<span
class="os-button__icon inline-flex items-center shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current"
>
<span
class="base-icon"
>
<!---->
</span>
</span>
</span>
<!---->
</button>
<button
aria-label="invite-codes.invalidate"
class="invalidate-button base-button --icon-only --circle"
class="invalidate-button inline-flex items-center justify-center [white-space-collapse:collapse] relative appearance-none font-semibold tracking-[0.05em] transition-[color,background-color] duration-[80ms] ease-[cubic-bezier(0.25,0.46,0.45,0.94)] cursor-pointer select-none border-[0.8px] border-solid focus:outline-1 disabled:pointer-events-none disabled:cursor-default focus:outline-dashed focus:outline-[var(--color-primary)] bg-transparent shadow-none disabled:border-[var(--color-disabled)] disabled:text-[var(--color-disabled)] h-[36px] min-w-[36px] text-[15px] leading-[normal] align-middle border-[var(--color-primary)] text-[var(--color-primary)] hover:bg-[var(--color-primary)] hover:text-[var(--color-primary-contrast)] active:bg-[var(--color-primary-active)] active:border-[var(--color-primary-active)] active:text-[var(--color-primary-contrast)] rounded-full p-0 w-[36px] invalidate-button"
data-appearance="outline"
data-variant="primary"
type="button"
>
<span
class="base-icon"
class="inline-flex items-center"
>
<!---->
<span
class="os-button__icon inline-flex items-center shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current"
>
<span
class="base-icon"
>
<!---->
</span>
</span>
</span>
<!---->
</button>
</div>
</li>

View File

@ -46,32 +46,46 @@ exports[`InvitationList.vue renders 1`] = `
>
<button
aria-label="invite-codes.copy-code"
class="copy-button base-button --icon-only --circle"
class="copy-button inline-flex items-center justify-center [white-space-collapse:collapse] relative appearance-none font-semibold tracking-[0.05em] transition-[color,background-color] duration-[80ms] ease-[cubic-bezier(0.25,0.46,0.45,0.94)] cursor-pointer select-none border-[0.8px] border-solid focus:outline-1 disabled:pointer-events-none disabled:cursor-default focus:outline-dashed focus:outline-[var(--color-primary)] bg-transparent shadow-none disabled:border-[var(--color-disabled)] disabled:text-[var(--color-disabled)] h-[36px] min-w-[36px] text-[15px] leading-[normal] align-middle border-[var(--color-primary)] text-[var(--color-primary)] hover:bg-[var(--color-primary)] hover:text-[var(--color-primary-contrast)] active:bg-[var(--color-primary-active)] active:border-[var(--color-primary-active)] active:text-[var(--color-primary-contrast)] rounded-full p-0 w-[36px] copy-button"
data-appearance="outline"
data-variant="primary"
type="button"
>
<span
class="base-icon"
class="inline-flex items-center"
>
<!---->
<span
class="os-button__icon inline-flex items-center shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current"
>
<span
class="base-icon"
>
<!---->
</span>
</span>
</span>
<!---->
</button>
<button
aria-label="invite-codes.invalidate"
class="invalidate-button base-button --icon-only --circle"
class="invalidate-button inline-flex items-center justify-center [white-space-collapse:collapse] relative appearance-none font-semibold tracking-[0.05em] transition-[color,background-color] duration-[80ms] ease-[cubic-bezier(0.25,0.46,0.45,0.94)] cursor-pointer select-none border-[0.8px] border-solid focus:outline-1 disabled:pointer-events-none disabled:cursor-default focus:outline-dashed focus:outline-[var(--color-primary)] bg-transparent shadow-none disabled:border-[var(--color-disabled)] disabled:text-[var(--color-disabled)] h-[36px] min-w-[36px] text-[15px] leading-[normal] align-middle border-[var(--color-primary)] text-[var(--color-primary)] hover:bg-[var(--color-primary)] hover:text-[var(--color-primary-contrast)] active:bg-[var(--color-primary-active)] active:border-[var(--color-primary-active)] active:text-[var(--color-primary-contrast)] rounded-full p-0 w-[36px] invalidate-button"
data-appearance="outline"
data-variant="primary"
type="button"
>
<span
class="base-icon"
class="inline-flex items-center"
>
<!---->
<span
class="os-button__icon inline-flex items-center shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current"
>
<span
class="base-icon"
>
<!---->
</span>
</span>
</span>
<!---->
</button>
</div>
</li>
@ -114,32 +128,46 @@ exports[`InvitationList.vue renders 1`] = `
>
<button
aria-label="invite-codes.copy-code"
class="copy-button base-button --icon-only --circle"
class="copy-button inline-flex items-center justify-center [white-space-collapse:collapse] relative appearance-none font-semibold tracking-[0.05em] transition-[color,background-color] duration-[80ms] ease-[cubic-bezier(0.25,0.46,0.45,0.94)] cursor-pointer select-none border-[0.8px] border-solid focus:outline-1 disabled:pointer-events-none disabled:cursor-default focus:outline-dashed focus:outline-[var(--color-primary)] bg-transparent shadow-none disabled:border-[var(--color-disabled)] disabled:text-[var(--color-disabled)] h-[36px] min-w-[36px] text-[15px] leading-[normal] align-middle border-[var(--color-primary)] text-[var(--color-primary)] hover:bg-[var(--color-primary)] hover:text-[var(--color-primary-contrast)] active:bg-[var(--color-primary-active)] active:border-[var(--color-primary-active)] active:text-[var(--color-primary-contrast)] rounded-full p-0 w-[36px] copy-button"
data-appearance="outline"
data-variant="primary"
type="button"
>
<span
class="base-icon"
class="inline-flex items-center"
>
<!---->
<span
class="os-button__icon inline-flex items-center shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current"
>
<span
class="base-icon"
>
<!---->
</span>
</span>
</span>
<!---->
</button>
<button
aria-label="invite-codes.invalidate"
class="invalidate-button base-button --icon-only --circle"
class="invalidate-button inline-flex items-center justify-center [white-space-collapse:collapse] relative appearance-none font-semibold tracking-[0.05em] transition-[color,background-color] duration-[80ms] ease-[cubic-bezier(0.25,0.46,0.45,0.94)] cursor-pointer select-none border-[0.8px] border-solid focus:outline-1 disabled:pointer-events-none disabled:cursor-default focus:outline-dashed focus:outline-[var(--color-primary)] bg-transparent shadow-none disabled:border-[var(--color-disabled)] disabled:text-[var(--color-disabled)] h-[36px] min-w-[36px] text-[15px] leading-[normal] align-middle border-[var(--color-primary)] text-[var(--color-primary)] hover:bg-[var(--color-primary)] hover:text-[var(--color-primary-contrast)] active:bg-[var(--color-primary-active)] active:border-[var(--color-primary-active)] active:text-[var(--color-primary-contrast)] rounded-full p-0 w-[36px] invalidate-button"
data-appearance="outline"
data-variant="primary"
type="button"
>
<span
class="base-icon"
class="inline-flex items-center"
>
<!---->
<span
class="os-button__icon inline-flex items-center shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current"
>
<span
class="base-icon"
>
<!---->
</span>
</span>
</span>
<!---->
</button>
</div>
</li>
@ -196,17 +224,24 @@ exports[`InvitationList.vue renders 1`] = `
<button
aria-label="invite-codes.generate-code"
class="generate-invite-code base-button --icon-only --circle"
class="generate-invite-code inline-flex items-center justify-center [white-space-collapse:collapse] relative appearance-none font-semibold tracking-[0.05em] transition-[color,background-color] duration-[80ms] ease-[cubic-bezier(0.25,0.46,0.45,0.94)] cursor-pointer select-none border-[0.8px] border-solid focus:outline-1 disabled:pointer-events-none disabled:cursor-default focus:outline-dashed focus:outline-[var(--color-primary)] bg-transparent shadow-none disabled:border-[var(--color-disabled)] disabled:text-[var(--color-disabled)] h-[36px] min-w-[36px] text-[15px] leading-[normal] align-middle border-[var(--color-primary)] text-[var(--color-primary)] hover:bg-[var(--color-primary)] hover:text-[var(--color-primary-contrast)] active:bg-[var(--color-primary-active)] active:border-[var(--color-primary-active)] active:text-[var(--color-primary-contrast)] rounded-full p-0 w-[36px] generate-invite-code"
data-appearance="outline"
data-variant="primary"
type="submit"
>
<span
class="base-icon"
class="inline-flex items-center"
>
<!---->
<span
class="os-button__icon inline-flex items-center shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current"
>
<span
class="base-icon"
>
<!---->
</span>
</span>
</span>
<!---->
</button>
</form>
</div>
@ -277,17 +312,24 @@ exports[`InvitationList.vue renders empty state 1`] = `
<button
aria-label="invite-codes.generate-code"
class="generate-invite-code base-button --icon-only --circle"
class="generate-invite-code inline-flex items-center justify-center [white-space-collapse:collapse] relative appearance-none font-semibold tracking-[0.05em] transition-[color,background-color] duration-[80ms] ease-[cubic-bezier(0.25,0.46,0.45,0.94)] cursor-pointer select-none border-[0.8px] border-solid focus:outline-1 disabled:pointer-events-none disabled:cursor-default focus:outline-dashed focus:outline-[var(--color-primary)] bg-transparent shadow-none disabled:border-[var(--color-disabled)] disabled:text-[var(--color-disabled)] h-[36px] min-w-[36px] text-[15px] leading-[normal] align-middle border-[var(--color-primary)] text-[var(--color-primary)] hover:bg-[var(--color-primary)] hover:text-[var(--color-primary-contrast)] active:bg-[var(--color-primary-active)] active:border-[var(--color-primary-active)] active:text-[var(--color-primary-contrast)] rounded-full p-0 w-[36px] generate-invite-code"
data-appearance="outline"
data-variant="primary"
type="submit"
>
<span
class="base-icon"
class="inline-flex items-center"
>
<!---->
<span
class="os-button__icon inline-flex items-center shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current"
>
<span
class="base-icon"
>
<!---->
</span>
</span>
</span>
<!---->
</button>
</form>
</div>

View File

@ -78,23 +78,23 @@ describe('MySomethingList.vue', () => {
})
it('displays the edit button', () => {
expect(wrapper.find('.base-button[data-test="edit-button"]').exists()).toBe(true)
expect(wrapper.find('button[data-test="edit-button"]').exists()).toBe(true)
})
it('displays the delete button', () => {
expect(wrapper.find('.base-button[data-test="delete-button"]').exists()).toBe(true)
expect(wrapper.find('button[data-test="delete-button"]').exists()).toBe(true)
})
})
describe('editing item', () => {
beforeEach(async () => {
const editButton = wrapper.find('.base-button[data-test="edit-button"]')
const editButton = wrapper.find('button[data-test="edit-button"]')
editButton.trigger('click')
await Vue.nextTick()
})
it('disables adding items while editing', () => {
const submitButton = wrapper.find('.base-button[data-test="add-save-button"]')
const submitButton = wrapper.find('button[data-test="add-save-button"]')
expect(submitButton.text()).not.toContain('settings.social-media.submit')
})
@ -109,7 +109,7 @@ describe('MySomethingList.vue', () => {
describe('calls callback functions', () => {
it('calls edit', async () => {
const editButton = wrapper.find('.base-button[data-test="edit-button"]')
const editButton = wrapper.find('button[data-test="edit-button"]')
editButton.trigger('click')
await Vue.nextTick()
const expectedItem = expect.objectContaining({ id: 'id', dummy: 'dummy' })
@ -134,7 +134,7 @@ describe('MySomethingList.vue', () => {
})
it('calls delete by committing "modal/SET_OPEN"', async () => {
const deleteButton = wrapper.find('.base-button[data-test="delete-button"]')
const deleteButton = wrapper.find('button[data-test="delete-button"]')
deleteButton.trigger('click')
await Vue.nextTick()
const expectedModalData = expect.objectContaining({

Some files were not shown because too many files have changed in this diff Show More