feat(package/ui): os-icon (#9234)

This commit is contained in:
Ulf Gebhardt 2026-02-15 21:31:00 +01:00 committed by GitHub
parent b60e270f4c
commit fc714a5f30
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
66 changed files with 1560 additions and 496 deletions

View File

@ -81,10 +81,10 @@ Phase 0: ██████████ 100% (6/6 Aufgaben) ✅
Phase 1: ██████████ 100% (6/6 Aufgaben) ✅
Phase 2: ██████████ 100% (26/26 Aufgaben) ✅
Phase 3: ██████████ 100% (24/24 Aufgaben) ✅ - Webapp-Integration komplett
Phase 4: █░░░░░░░░░ 6% (1/17 Aufgaben) - OsButton
Phase 4: ██░░░░░░░░ 18% (3/17 Aufgaben) - OsButton ✅, OsIcon ✅, System-Icons
Phase 5: ░░░░░░░░░░ 0% (0/7 Aufgaben)
───────────────────────────────────────
Gesamt: ████████░░ 74% (63/86 Aufgaben)
Gesamt: ████████░░ 76% (65/86 Aufgaben)
```
### Katalogisierung (Details in KATALOG.md)
@ -115,21 +115,49 @@ OsButton Features:
as-Prop Migration: 15 <nuxt-link>/<a>-Wrapper in 15 Webapp-Dateien → as="nuxt-link"/as="a"
```
### OsIcon (Phase 4)
```
OsIcon Features:
├─ name: ✅ System-Icon per Name (check, close, plus)
├─ icon: ✅ Custom Vue-Komponente (hat Vorrang vor name)
├─ size: ✅ xs, sm, md, lg, xl, 2xl (em-basiert)
├─ a11y: ✅ decorative (default) / semantic (mit aria-label)
├─ color: ✅ fill-current (erbt von Parent)
└─ svg-plugin: ✅ vite-svg-icon (SVG → Vue Component via ?icon)
System-Icons:
├─ check.svg (Checkmark)
├─ close.svg (Close/X)
└─ plus.svg (Plus/Add)
Ocelot-Icons (separates Entry-Point):
└─ angle-down.svg (Dropdown-Pfeil)
```
---
## Aktueller Stand
**Letzte Aktualisierung:** 2026-02-14 (Session 20)
**Letzte Aktualisierung:** 2026-02-15 (Session 21)
**Aktuelle Phase:** Phase 3 ✅ ABGESCHLOSSEN + Code-Review-Feedback + `as`-Prop-Migration
**Aktuelle Phase:** Phase 4 - OsIcon ✅ implementiert, System-Icons eingerichtet
**Abgeschlossene Phasen:**
- [x] Phase 0: Analyse (177 Komponenten katalogisiert)
- [x] Phase 1: Vue 2.7 Upgrade (2.6.14 → 2.7.16, 979 Tests ✅)
- [x] Phase 2: Projekt-Setup (Vite, vue-demi, Tailwind v4, CVA, Storybook 10, CI/CD, 100% Coverage)
- [x] Phase 3: Webapp-Integration — 133 os-button in 79 Dateien, 0 base-button/ds-button verbleibend
**Zuletzt abgeschlossen (Session 21 - OsIcon Komponente, System-Icons, Ocelot-Umbenennung):**
- [x] OsIcon Komponente implementiert (name, icon, size Props; Vue 2/3 via vue-demi h())
- [x] System-Icons: check, close, plus (SVG, viewBox 0 0 32 32, stroke-basiert)
- [x] Custom vite-svg-icon Plugin: SVG → Vue Render-Function via `?icon` Query
- [x] Icon-Größen: xs(0.75em), sm(0.875em), md(1.2em), lg(1.5em), xl(2em), 2xl(2.5em)
- [x] A11y: decorative (aria-hidden, default) / semantic (role="img" + aria-label)
- [x] fill-current für Farbvererbung vom Parent
- [x] OsButton nutzt OsIcon statt inline SVG für Icon-Rendering
- [x] Ocelot-Icons: separates Entry-Point (ocelot.mjs) mit dynamischem Loading via import.meta.glob
- [x] `src/webapp/``src/ocelot/` umbenannt (konsistentes Naming)
- [x] check-completeness erweitert: unterstützt ocelot/ Verzeichnis
- [x] OsIcon: 211 Zeilen Unit-Tests, Visual Tests mit checkA11y(), Keyboard A11y
- [x] 100% Test-Coverage für OsIcon
- [x] OsButton Stories bereinigt (OsIcon statt Inline-SVGs)
**Zuletzt abgeschlossen (Session 20 - `as`-Prop + nuxt-link Migration):**
**Zuvor abgeschlossen (Session 20 - `as`-Prop + nuxt-link Migration):**
- [x] OsButton: `as` Prop implementiert (polymorphe Komponente: `button`, `a`, `nuxt-link`, `router-link`, Custom-Komponenten)
- [x] Naming-Konvention: `tag``as` (moderner Standard: Headless UI, Radix Vue, Chakra UI, PrimeVue)
- [x] `disabled`/`type`/`loading` nur bei `as="button"` (Links haben kein natives `disabled`-Attribut)
@ -146,246 +174,34 @@ as-Prop Migration: 15 <nuxt-link>/<a>-Wrapper in 15 Webapp-Dateien → as="nuxt
- NotificationMenu.vue (3 Instanzen, 2 zu einem Button konsolidiert via counter-icon)
- [x] Verifiziert: 0 verbleibende `<nuxt-link>`/`<a>`-Wrapper um `<os-button>` in Webapp
**Zuvor 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 erledigt (auf master gemergt):**
- [x] `os-button` CSS-Klasse auf Button-Element für Branding-Kompatibilität (#9211)
- [x] eslint-config-it4c v0.11.2 Update: Flat Config, path alias #src, CSS-Linting (#9233)
- [x] Release @ocelot-social/ui v0.0.2
- [x] Release v3.14.1
**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
**Abgeschlossene Phasen:**
- [x] Phase 0: Analyse (177 Komponenten katalogisiert)
- [x] Phase 1: Vue 2.7 Upgrade (2.6.14 → 2.7.16, 979 Tests ✅)
- [x] Phase 2: Projekt-Setup (Vite, vue-demi, Tailwind v4, CVA, Storybook 10, CI/CD, 100% Coverage)
- [x] Phase 3: Webapp-Integration — 133 os-button in 79 Dateien, 0 base-button/ds-button verbleibend
**Zuvor abgeschlossen (Session 17 - 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`
- [x] CI-Workflow-Trigger optimiert: 9 UI-Workflows von `on: push` auf Branch+Path-Filter (`master`, `packages/ui/**`)
- [x] `custom-class``class` Migration: 4 Stellen in 3 Webapp-Dateien (notifications, MapStylesButtons, EmbedComponent)
- [x] Vue 3 Template-Fix: `this.$t()``$t()` in CommentCard.vue (Zeile 5 + 58)
- [x] Pre-existing Fix: `async` Arrow-Function in OsButton.visual.spec.ts
**Zuvor abgeschlossen (Session 11 - Storybook & Code-Review Fixes):**
- [x] Wasserfarben-Farbschema für Storybook (Ultramarin, Dioxazin-Violett, Alizarin, Ocker, Viridian, Cöruleum)
- [x] Stories erweitert: Playground (interaktive Controls), alle Varianten in allen Stories
- [x] Einzelne Stories (Primary, Secondary, Danger, Default) durch AllVariants ersetzt
- [x] AllAppearances zeigt alle 7 Varianten × 3 Appearances
- [x] Einheitlicher Border (0.8px) über alle Appearances (kein Layout-Shift mehr)
- [x] WCAG 2.4.7 Fix: Default-Variante hat jetzt `focus:outline-dashed focus:outline-current`
- [x] Keyboard Accessibility Test: prüft Focus-Indikator auf allen Buttons im Browser
- [x] `data-appearance` Attribut: robuste CSS-Selektoren statt fragile escaped Tailwind-Klassen
- [x] Code-Review Feedback eingearbeitet (Unit-Tests, Testnamen, CSS-Selektoren)
**Zuvor abgeschlossen (Sessions 9-10 - Milestone 5, Analyse, Disabled-Styles):**
- [x] Visuelle Validierung: 16/16 Buttons validiert ✅
- [x] OsButton Features: `appearance` (outline, ghost), `xs` size, focus/active states
- [x] Disabled-Styles: CSS-Variablen, hover/active-Override, Border-Fix
- [x] Codebase-Analyse: 14 weitere migrierbare Buttons identifiziert (Scope: 16/35)
**Zuvor abgeschlossen (Sessions 1-8 - Phase 3 Webapp-Integration):**
- [x] vue-demi zur Webapp hinzugefügt (Vue 2.7 Kompatibilität)
- [x] Webpack-Alias für vue-demi (nutzt Webapp's Vue 2.7 statt UI-Library's Vue 3)
- [x] Webpack-Alias für @ocelot-social/ui (dist Pfade mit $ für exakten Match)
- [x] OsButton mit isVue2 Render-Funktion (Vue 2: attrs-Objekt, Vue 3: flat props)
- [x] CSS-Reihenfolge angepasst (UI-Library nach Styleguide für korrekte Spezifität)
- [x] Manueller visueller Vergleich ✅
- [x] **Jest-Integration für vue-demi**
- Custom Mock (`test/__mocks__/@ocelot-social/ui.js`) statt direktem Import
- Problem: Jest's moduleNameMapper greift nicht für verschachtelte requires in CJS
- Problem: Jest lädt `vue.runtime.common.js` mit exports unter `default`
- Lösung: Module._load Patch für vue-demi + defineComponent von Vue.default
- Setup-File (`test/vueDemiSetup.js`) für Module._resolveFilename Patch
- **979 Tests bestehen ✅**
- [x] Button-Variants an ds-button angepasst (font-semibold, rounded, box-shadow)
- [x] UserTeaserPopover.vue migriert (verwendet `<os-button>`)
- [x] **Docker Build für UI-Library**
- ui-library Stage in Dockerfile + Dockerfile.maintenance
- COPY --from=ui-library ./app/ /packages/ui/
- [x] **CI-Kompatibilität**
- Relativer Pfad `file:../packages/ui` statt absolut `/packages/ui`
- Funktioniert lokal, in CI und in Docker
- [x] **OsButton attrs/listeners Forwarding**
- getCurrentInstance() für $listeners Zugriff in Vue 2
- inheritAttrs: false für manuelle Weiterleitung
- Jest Mock um alle Composition API Funktionen erweitert
- [x] **16 Buttons migriert** (ohne icon/circle/loading) ✅
- GroupForm.vue, EmbedComponent.vue, DonationInfo.vue, CommentCard.vue
- MapStylesButtons.vue, GroupMember.vue, embeds.vue
- notifications.vue, privacy.vue, terms-and-conditions-confirm.vue, UserTeaserPopover.vue
- [x] **Disabled-Styles korrigiert**
- CSS-Variablen `--color-disabled` und `--color-disabled-contrast` hinzugefügt
- Filled-Buttons: Grauer Hintergrund statt opacity (wie buttonStates Mixin)
- Outline/Ghost: Graue Border/Text
- [x] terms-and-conditions-confirm.vue: Read T&C Button → `appearance="outline" variant="primary"`
- [x] **Disabled:active/hover Spezifität**
- CSS-Regeln in index.css mit höherer Spezifität für disabled:hover und disabled:active
- Button zeigt sofort disabled-Farben, auch wenn während :active disabled wird
- [x] notifications.vue: Check All + Uncheck All → `appearance="outline" variant="primary"`
- [x] embeds.vue: Allow All → `appearance="outline" variant="primary"`
- [x] **Disabled Border-Fix**
- CSS-Regeln in index.css: `border-style: solid` und `border-width: 0.8px` bei disabled
- Verhindert Layout-Sprung wenn Button disabled wird
**Zuvor abgeschlossen (Sessions 11-19 — Details im Arbeitsprotokoll §12):**
- [x] Session 19: CodeRabbit Review Cleanup, ~30 Bugfixes + A11y-Verbesserungen
- [x] Session 18: CodeRabbit Review data-test Selektoren, ~25 A11y aria-labels, OsButton Refactoring
- [x] Session 16: Letzte ds-button Migration, Bugfixes, data-variant Attribut
- [x] Session 15: Milestone 4c — 59 Buttons migriert, 0 base-button verbleibend
- [x] Session 14: Loading Prop, Circle Prop, Spinner-Architektur, Code-Optimierung
- [x] Session 13: Icon-Slot, Storybook Playground, 6 Icon-Buttons migriert
- [x] Session 12: CSS-Linting, CI-Optimierung, Code-Review Fixes
- [x] Session 11: Wasserfarben-Farbschema, Stories konsolidiert, Keyboard A11y
**Nächste Schritte:**
- [ ] Snapshots/Tests aktualisieren (nach `as`-Prop-Migration)
- [ ] 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)
- [ ] BaseButton-Komponente ggf. entfernen
- [ ] OsSpinner Komponente (vereint DsSpinner + LoadingSpinner)
- [ ] OsCard Komponente (vereint DsCard + BaseCard)
- [ ] Weitere Tier 1 Komponenten
- [ ] BaseIcon → OsIcon Webapp-Migration (131 Nutzungen)
- [ ] Snapshots/Tests aktualisieren
**Manuelle Setup-Aufgaben (außerhalb Code):**
- [ ] `NPM_TOKEN` als GitHub Secret einrichten (für npm publish in ui-release.yml)
@ -635,7 +451,7 @@ Jeder migrierte Button muss manuell geprüft werden: Normal, Hover, Focus, Activ
### Phase 4: Komponenten-Migration (15 Komponenten + 2 Infrastruktur)
**Tier 1: Kern-Komponenten**
- [ ] OsIcon (vereint DsIcon + BaseIcon)
- [x] OsIcon (vereint DsIcon + BaseIcon) ✅ System-Icons + vite-svg-icon Plugin
- [ ] OsSpinner (vereint DsSpinner + LoadingSpinner)
- [x] OsButton (vereint DsButton + BaseButton) ✅ Entwickelt in Phase 2
- [ ] OsCard (vereint DsCard + BaseCard)
@ -658,7 +474,7 @@ Jeder migrierte Button muss manuell geprüft werden: Normal, Hover, Focus, Activ
- [ ] OsTag
**Infrastruktur**
- [ ] System-Icons einrichten
- [x] System-Icons einrichten ✅ vite-svg-icon Plugin, 3 System-Icons, Ocelot-Icons Entry-Point
- [ ] CI docs-check Workflow (JSDoc-Coverage, README-Aktualität)
### Phase 5: Finalisierung
@ -858,17 +674,15 @@ Die Library verwendet eine **Hybrid-Architektur** für Icons:
┌─────────────────────────────────────────────────────────────┐
@ocelot-social/ui (Library) │
├─────────────────────────────────────────────────────────────┤
│ icons/system/ # ~10 System-Icons │
│ src/components/OsIcon/icons/svgs/ # System-Icons │
│ ├── check.svg # Bestätigung, Checkboxen │
│ ├── close.svg # Modal, Chip, Dialoge │
│ ├── check.svg # Modal confirm, Checkboxen │
│ ├── chevron-down.svg # Select, Dropdown │
│ ├── chevron-up.svg # Select, Accordion │
│ ├── spinner.svg # Loading-States │
│ ├── bars.svg # Hamburger-Menu │
│ ├── copy.svg # CopyField │
│ ├── eye.svg # Password-Toggle │
│ ├── eye-slash.svg # Password-Toggle, Anonym │
│ └── search.svg # Search-Input │
│ └── plus.svg # Hinzufügen, Erstellen │
│ │
│ src/ocelot/icons/svgs/ # Ocelot-Icons │
│ └── angle-down.svg # Dropdown-Pfeil │
│ │
│ (Weitere System-Icons werden bei Bedarf ergänzt) │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
@ -890,54 +704,61 @@ Die Library verwendet eine **Hybrid-Architektur** für Icons:
3. **Feature-Icons gehören zur App** - Icons wie `user`, `bell`, `heart` sind Business-Logik
4. **Branding-Flexibilität** - Verschiedene Ocelot-Instanzen können unterschiedliche Icon-Sets verwenden
### System-Icons (in Library enthalten)
### System-Icons (in Library enthalten) ✅
| Icon | Verwendung | Status |
|------|------------|--------|
| `check` | Bestätigung, Checkboxen | ✅ implementiert |
| `close` | Modal, Chip, Dialoge | ✅ implementiert |
| `plus` | Hinzufügen, Erstellen | ✅ implementiert |
**Geplant (bei Bedarf ergänzen):**
| Icon | Verwendung in Komponenten |
|------|---------------------------|
| `close` | OsModal, OsChip, OsDialog, OsAlert |
| `check` | OsModal (confirm), OsCheckbox |
| `chevron-down` | OsSelect, OsDropdown, OsAccordion |
| `chevron-up` | OsSelect, OsAccordion |
| `spinner` | OsButton (loading), OsSpinner |
| `bars` | OsPage (mobile menu) |
| `copy` | OsCopyField |
| `eye` | OsInput (password toggle) |
| `eye-slash` | OsInput (password toggle), OsAvatar (anonym) |
| `search` | OsInput (search variant) |
### API-Design
### API-Design (implementiert)
```typescript
// OsIcon akzeptiert verschiedene Formate:
// 1. System-Icon (String) - aus Library
// OsIcon — System-Icon per Name
<OsIcon name="close" />
<OsIcon name="check" size="lg" />
// 2. Vue-Komponente - für App-Icons
// OsIcon — Custom Vue-Komponente (hat Vorrang vor name)
<OsIcon :icon="UserIcon" />
// 3. In Komponenten mit icon-Prop
<OsButton icon="close" /> // System-Icon
<OsButton :icon="CustomIcon" /> // Komponente
// OsIcon — Semantic (mit aria-label)
<OsIcon name="close" aria-label="Schließen" />
// OsButton — Icon über #icon Slot (icon-system-agnostisch)
<OsButton variant="primary">
<template #icon><OsIcon name="check" /></template>
Speichern
</OsButton>
```
### Webapp-Integration
### SVG-Loading (vite-svg-icon Plugin)
```typescript
// webapp/plugins/icons.ts
import { provideIcons } from '@ocelot-social/ui'
import * as appIcons from '~/assets/icons'
// SVGs werden via ?icon Query als Vue-Komponenten geladen
import CheckIcon from './check.svg?icon'
export default defineNuxtPlugin((nuxtApp) => {
// App-Icons global registrieren
provideIcons(appIcons)
})
// Plugin extrahiert viewBox + <path> und transformiert zu:
// h('svg', { viewBox, ... }, [h('path', { d })])
```
```vue
<!-- Dann in der Webapp nutzbar -->
<OsButton :icon="icons.user" />
<OsIcon :icon="icons.bell" />
### Ocelot-Icons (separates Entry-Point)
```typescript
// Dynamisches Loading via import.meta.glob
import { ocelotIcons } from '@ocelot-social/ui/ocelot'
// Filename → PascalCase: angle-down.svg → IconAngleDown
// Returns Record<string, () => VNode>
```
### Aktuelle Icon-Statistik
@ -946,7 +767,8 @@ export default defineNuxtPlugin((nuxtApp) => {
|--------|--------|--------|
| Styleguide (_all) | 616 | Nicht übernehmen (FontAwesome 4 komplett) |
| Webapp (svgs) | 238 | Feature-Icons, bleiben in Webapp |
| **Library (system)** | **~10** | Nur essenzielle System-Icons |
| **Library (system)** | **3** | ✅ check, close, plus |
| **Ocelot-Icons** | **1** | ✅ angle-down (separates Entry-Point) |
---
@ -1696,6 +1518,10 @@ Bei der Migration werden:
| 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) |
| 2026-02-13 | **OsButton Klasse** | `os-button` CSS-Klasse auf Button-Element für Branding-Kompatibilität (#9211) |
| 2026-02-14 | **ESLint Config Update** | eslint-config-it4c v0.11.2: Flat Config, path alias `#src`, CSS-Linting, security/detect-non-literal-fs-filename, n/no-sync, n/shebang (#9233) |
| 2026-02-14 | **check-completeness** | Parallelisiertes File-Reading, breitere Regex für Keyboard-Tests, Playground-Tests ignoriert |
| 2026-02-14 | **Release v0.0.2** | @ocelot-social/ui v0.0.2 veröffentlicht |
| 2026-02-14 | **`as` Prop** | Polymorphe OsButton-Komponente: `as` Prop für dynamischen Tag/Komponente (`button`, `a`, `nuxt-link`, `router-link`); moderner Standard (Headless UI, Radix Vue) |
| 2026-02-14 | **Naming: tag → as** | `tag``as` umbenannt nach Recherche moderner UI-Libraries (Headless UI, Radix Vue, Chakra UI, PrimeVue nutzen `as`) |
| 2026-02-14 | **Disabled nur für button** | `disabled`/`type`/`loading` nur bei `as="button"` (Links haben kein natives `disabled`); `aria-disabled`/`tabindex` Logik entfernt |
@ -1705,6 +1531,18 @@ Bei der Migration werden:
| 2026-02-14 | **NotificationMenu konsolidiert** | 2 separate Buttons (kein Badge / mit Badge) zu einem zusammengeführt — counter-icon zeigt bei `count=0` kein Badge |
| 2026-02-14 | **CSS-Selektor Fix** | pages/index.vue: `button.post-add-button-top/bottom``.post-add-button-top/bottom` (nuxt-link rendert `<a>`, nicht `<button>`) |
| 2026-02-14 | **Profil-Spacing** | profile/_id/_slug.vue: `v-if` auf ds-grid-item (kein leerer Abstand), symmetrisches Padding `$space-x-small` |
| 2026-02-15 | **OsIcon Komponente** | Neue Komponente: `name` (System-Icon), `icon` (Custom Component), `size` (xs-2xl); Vue 2/3 via vue-demi h() |
| 2026-02-15 | **vite-svg-icon Plugin** | Custom Vite Plugin: extrahiert viewBox + `<path>` aus SVG, transformiert zu Vue h()-Aufruf via `?icon` Query |
| 2026-02-15 | **System-Icons** | 3 SVG-Icons in Library: check, close, plus (viewBox 0 0 32 32, stroke-basiert); SYSTEM_ICONS Registry + SystemIconName Type |
| 2026-02-15 | **Icon-Größen** | CVA-Varianten: xs(0.75em), sm(0.875em), md(1.2em default), lg(1.5em), xl(2em), 2xl(2.5em) — em-basiert für Kontext-Skalierung |
| 2026-02-15 | **Icon A11y** | Decorative: `aria-hidden="true"` (default); Semantic: `role="img"` + `aria-label` wenn Label-Prop vorhanden |
| 2026-02-15 | **OsIcon in OsButton** | OsButton nutzt OsIcon statt inline SVG-Elemente für Icon-Slot-Rendering |
| 2026-02-15 | **Ocelot-Icons** | Separates Entry-Point (`ocelot.mjs`): dynamisches Icon-Loading via `import.meta.glob('**/*.svg', { query: '?icon' })` |
| 2026-02-15 | **webapp → ocelot** | `src/webapp/``src/ocelot/` umbenannt; Stories, Tests, Exports angepasst; konsistentes Naming |
| 2026-02-15 | **OsIcon Tests** | 211 Zeilen Unit-Tests (Rendering, Sizes, A11y, CSS, System-Icons); Visual Tests mit checkA11y(); Keyboard A11y; 100% Coverage |
| 2026-02-15 | **OsButton Stories** | Bereinigt: Inline-SVG-Komponenten durch OsIcon ersetzt; WithAriaLabel-Story entfernt; InheritColor-Story vereinfacht |
| 2026-02-15 | **check-completeness** | Erweitert für ocelot/ Verzeichnis; unterstützt OsIcon-Patterns |
| 2026-02-15 | **svg-icon.d.ts** | TypeScript-Deklaration für `?icon` Import-Query (Component-Typ) |
---

View File

@ -55,6 +55,13 @@ export default [
...vuejsAccessibilityPlugin.configs.recommended.rules,
},
},
{
// Allow .svg?icon imports (custom Vite plugin)
files: ['src/**/icons/index.ts'],
rules: {
'n/file-extension-in-import': 'off',
},
},
...css,
{
// Extend CSS config with Tailwind v4 syntax support

View File

@ -18,6 +18,16 @@
"default": "./dist/index.cjs"
}
},
"./ocelot": {
"import": {
"types": "./dist/ocelot.d.ts",
"default": "./dist/ocelot.mjs"
},
"require": {
"types": "./dist/ocelot.d.cts",
"default": "./dist/ocelot.cjs"
}
},
"./style.css": "./dist/style.css",
"./tailwind.preset": {
"style": "./dist/tailwind.preset.mjs",
@ -134,13 +144,18 @@
"size-limit": [
{
"path": "dist/index.mjs",
"limit": "15 kB",
"limit": "16 kB",
"brotli": true
},
{
"path": "dist/tailwind.preset.mjs",
"limit": "2 kB"
},
{
"path": "dist/ocelot.mjs",
"limit": "5 kB",
"brotli": true
},
{
"path": "dist/style.css",
"limit": "10 kB"

View File

@ -8,7 +8,7 @@ import { defineConfig, devices } from '@playwright/test'
* Baseline images are stored in e2e/__screenshots__ and committed to git.
*/
export default defineConfig({
testDir: './src/components',
testDir: './src',
testMatch: '**/*.visual.spec.ts',
/* Run tests in parallel */

View File

@ -42,11 +42,58 @@ interface CheckResult {
warnings: string[]
}
function checkVisualTests(
visualTestContent: string | null,
visualTestPath: string,
errors: string[],
): void {
if (visualTestContent === null) {
errors.push(`Missing visual test file: ${visualTestPath}`)
} else if (!visualTestContent.includes('checkA11y(')) {
errors.push(`Missing checkA11y() calls in visual tests: ${visualTestPath}`)
}
}
function checkKeyboardA11y(
unitTestContent: string | null,
unitTestPath: string,
errors: string[],
): void {
if (unitTestContent !== null) {
if (!/describe\(\s*['"]keyboard accessibility['"]/.test(unitTestContent)) {
errors.push(`Missing keyboard accessibility tests in: ${unitTestPath}`)
}
} else {
errors.push(`Missing unit test file: ${unitTestPath}`)
}
}
function checkStoryCoverage(
storyContent: string,
visualTestContent: string,
warnings: string[],
): void {
const storyExports = storyContent.matchAll(/export\s+const\s+(\w+):\s*Story/g)
for (const match of storyExports) {
const storyName = match[1]
if (storyName === 'Playground') continue
const kebabName = toKebabCase(storyName)
if (!visualTestContent.includes(`--${kebabName}`)) {
warnings.push(`Story "${storyName}" missing visual test (--${kebabName})`)
}
}
}
const results: CheckResult[] = []
let hasErrors = false
// Find all Vue components (excluding index files)
const components = await glob('src/components/**/Os*.vue')
const components = [
...(await glob('src/components/**/Os*.vue')),
...(await glob('src/ocelot/components/**/*.vue')),
]
for (const componentPath of components) {
const componentName = basename(componentPath, '.vue')
@ -56,7 +103,7 @@ for (const componentPath of components) {
const unitTestPath = join(componentDir, `${componentName}.spec.ts`)
const variantsPath = join(
componentDir,
`${componentName.toLowerCase().replace('os', '')}.variants.ts`,
`${componentName.replace(/^Os/, '').toLowerCase()}.variants.ts`,
)
const result: CheckResult = {
@ -78,38 +125,15 @@ for (const componentPath of components) {
result.errors.push(`Missing story file: ${storyPath}`)
}
// Check 2: Visual regression test file exists
if (visualTestContent === null) {
result.errors.push(`Missing visual test file: ${visualTestPath}`)
}
// Check 3: Visual tests include accessibility checks
if (visualTestContent !== null && !visualTestContent.includes('checkA11y(')) {
result.errors.push(`Missing checkA11y() calls in visual tests: ${visualTestPath}`)
}
// Check 2 & 3: Visual regression tests with a11y checks
checkVisualTests(visualTestContent, visualTestPath, result.errors)
// Check 4: Keyboard accessibility tests exist
if (unitTestContent !== null) {
if (!/describe\(\s*['"]keyboard accessibility['"]/.test(unitTestContent)) {
result.errors.push(`Missing keyboard accessibility tests in: ${unitTestPath}`)
}
} else {
result.errors.push(`Missing unit test file: ${unitTestPath}`)
}
checkKeyboardA11y(unitTestContent, unitTestPath, result.errors)
// Check 5 & 6: Story and visual test coverage
if (storyContent !== null && visualTestContent !== null) {
const storyExports = storyContent.matchAll(/export\s+const\s+(\w+):\s*Story/g)
for (const match of storyExports) {
const storyName = match[1]
if (storyName === 'Playground') continue
const kebabName = toKebabCase(storyName)
if (!visualTestContent.includes(`--${kebabName}`)) {
result.warnings.push(`Story "${storyName}" missing visual test (--${kebabName})`)
}
}
checkStoryCoverage(storyContent, visualTestContent, result.warnings)
}
// Check 5: Variant values are demonstrated in stories
@ -151,6 +175,47 @@ for (const componentPath of components) {
}
}
// --- Ocelot stories (no .vue files, story-driven checks) ---
const ocelotStories = await glob('src/ocelot/**/*.stories.ts')
for (const storyPath of ocelotStories) {
const storyName = basename(storyPath, '.stories.ts')
const storyDir = dirname(storyPath)
const visualTestPath = join(storyDir, `${storyName}.visual.spec.ts`)
const unitTestPath = join(storyDir, 'index.spec.ts')
const result: CheckResult = {
component: storyName,
errors: [],
warnings: [],
}
const [storyContent, visualTestContent, unitTestContent] = await Promise.all([
tryReadFile(storyPath),
tryReadFile(visualTestPath),
tryReadFile(unitTestPath),
])
// Check: Visual regression tests with a11y checks
checkVisualTests(visualTestContent, visualTestPath, result.errors)
// Check: Keyboard accessibility tests exist
checkKeyboardA11y(unitTestContent, unitTestPath, result.errors)
// Check: Story-to-visual-test coverage
if (storyContent !== null && visualTestContent !== null) {
checkStoryCoverage(storyContent, visualTestContent, result.warnings)
}
if (result.errors.length > 0 || result.warnings.length > 0) {
results.push(result)
}
if (result.errors.length > 0) {
hasErrors = true
}
}
// Output results
if (results.length === 0) {
console.log('✓ All completeness checks passed!')

View File

@ -1,35 +1,11 @@
import { computed, h } from 'vue'
import { computed } from 'vue'
import { IconCheck, IconClose, IconPlus } from '#src/components/OsIcon'
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',
}),
])
import type { Component } from 'vue'
const meta: Meta<typeof OsButton> = {
title: 'Components/OsButton',
@ -54,11 +30,11 @@ interface PlaygroundArgs {
label: string
}
const iconMap: Record<string, (() => ReturnType<typeof h>) | null> = {
const iconMap: Record<string, Component | null> = {
none: null,
check: CheckIcon,
close: CloseIcon,
plus: PlusIcon,
check: IconCheck,
close: IconClose,
plus: IconPlus,
}
export const Playground: StoryObj<PlaygroundArgs> = {
@ -371,27 +347,27 @@ export const FullWidth: Story = {
export const Icon: Story = {
render: () => ({
components: { OsButton, CheckIcon, PlusIcon, CloseIcon },
components: { OsButton, IconCheck, IconPlus, IconClose },
template: `
<div class="flex flex-wrap gap-2">
<OsButton variant="primary">
<template #icon><CheckIcon /></template>
<template #icon><IconCheck /></template>
Save
</OsButton>
<OsButton variant="success">
<template #icon><CheckIcon /></template>
<template #icon><IconCheck /></template>
Confirm
</OsButton>
<OsButton variant="default">
<template #icon><PlusIcon /></template>
<template #icon><IconPlus /></template>
Add
</OsButton>
<OsButton variant="danger">
<template #icon><CloseIcon /></template>
<template #icon><IconClose /></template>
Delete
</OsButton>
<OsButton variant="info">
<template #icon><PlusIcon /></template>
<template #icon><IconPlus /></template>
Create
</OsButton>
</div>
@ -401,23 +377,23 @@ export const Icon: Story = {
export const IconOnly: Story = {
render: () => ({
components: { OsButton, CloseIcon, PlusIcon, CheckIcon },
components: { OsButton, IconClose, IconPlus, IconCheck },
template: `
<div class="flex flex-wrap gap-2">
<OsButton variant="danger" aria-label="Close">
<template #icon><CloseIcon /></template>
<template #icon><IconClose /></template>
</OsButton>
<OsButton variant="primary" aria-label="Add">
<template #icon><PlusIcon /></template>
<template #icon><IconPlus /></template>
</OsButton>
<OsButton variant="success" aria-label="Confirm">
<template #icon><CheckIcon /></template>
<template #icon><IconCheck /></template>
</OsButton>
<OsButton variant="default" aria-label="Close" appearance="outline">
<template #icon><CloseIcon /></template>
<template #icon><IconClose /></template>
</OsButton>
<OsButton variant="primary" aria-label="Add" appearance="ghost">
<template #icon><PlusIcon /></template>
<template #icon><IconPlus /></template>
</OsButton>
</div>
`,
@ -426,23 +402,23 @@ export const IconOnly: Story = {
export const IconSizes: Story = {
render: () => ({
components: { OsButton, CheckIcon },
components: { OsButton, IconCheck },
template: `
<div class="flex flex-wrap gap-2 items-center">
<OsButton size="sm" variant="primary">
<template #icon><CheckIcon /></template>
<template #icon><IconCheck /></template>
Small
</OsButton>
<OsButton size="md" variant="primary">
<template #icon><CheckIcon /></template>
<template #icon><IconCheck /></template>
Medium
</OsButton>
<OsButton size="lg" variant="primary">
<template #icon><CheckIcon /></template>
<template #icon><IconCheck /></template>
Large
</OsButton>
<OsButton size="xl" variant="primary">
<template #icon><CheckIcon /></template>
<template #icon><IconCheck /></template>
Extra Large
</OsButton>
</div>
@ -452,22 +428,22 @@ export const IconSizes: Story = {
export const IconAppearances: Story = {
render: () => ({
components: { OsButton, CheckIcon },
components: { OsButton, IconCheck },
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>
<template #icon><IconCheck /></template>
Primary
</OsButton>
<OsButton appearance="filled" variant="danger">
<template #icon><CheckIcon /></template>
<template #icon><IconCheck /></template>
Danger
</OsButton>
<OsButton appearance="filled" variant="success">
<template #icon><CheckIcon /></template>
<template #icon><IconCheck /></template>
Success
</OsButton>
</div>
@ -476,15 +452,15 @@ export const IconAppearances: Story = {
<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>
<template #icon><IconCheck /></template>
Primary
</OsButton>
<OsButton appearance="outline" variant="danger">
<template #icon><CheckIcon /></template>
<template #icon><IconCheck /></template>
Danger
</OsButton>
<OsButton appearance="outline" variant="success">
<template #icon><CheckIcon /></template>
<template #icon><IconCheck /></template>
Success
</OsButton>
</div>
@ -493,15 +469,15 @@ export const IconAppearances: Story = {
<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>
<template #icon><IconCheck /></template>
Primary
</OsButton>
<OsButton appearance="ghost" variant="danger">
<template #icon><CheckIcon /></template>
<template #icon><IconCheck /></template>
Danger
</OsButton>
<OsButton appearance="ghost" variant="success">
<template #icon><CheckIcon /></template>
<template #icon><IconCheck /></template>
Success
</OsButton>
</div>
@ -513,29 +489,29 @@ export const IconAppearances: Story = {
export const Circle: Story = {
render: () => ({
components: { OsButton, PlusIcon, CloseIcon, CheckIcon },
components: { OsButton, IconPlus, IconClose, IconCheck },
template: `
<div class="flex flex-wrap gap-2 items-center">
<OsButton circle variant="default" aria-label="Add">
<template #icon><PlusIcon /></template>
<template #icon><IconPlus /></template>
</OsButton>
<OsButton circle variant="primary" aria-label="Add">
<template #icon><PlusIcon /></template>
<template #icon><IconPlus /></template>
</OsButton>
<OsButton circle variant="secondary" aria-label="Confirm">
<template #icon><CheckIcon /></template>
<template #icon><IconCheck /></template>
</OsButton>
<OsButton circle variant="danger" aria-label="Close">
<template #icon><CloseIcon /></template>
<template #icon><IconClose /></template>
</OsButton>
<OsButton circle variant="warning" aria-label="Close">
<template #icon><CloseIcon /></template>
<template #icon><IconClose /></template>
</OsButton>
<OsButton circle variant="success" aria-label="Confirm">
<template #icon><CheckIcon /></template>
<template #icon><IconCheck /></template>
</OsButton>
<OsButton circle variant="info" aria-label="Add">
<template #icon><PlusIcon /></template>
<template #icon><IconPlus /></template>
</OsButton>
</div>
`,
@ -544,20 +520,20 @@ export const Circle: Story = {
export const CircleSizes: Story = {
render: () => ({
components: { OsButton, PlusIcon },
components: { OsButton, IconPlus },
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>
<template #icon><IconPlus /></template>
</OsButton>
<OsButton circle size="sm" variant="danger" aria-label="Add">
<template #icon><PlusIcon /></template>
<template #icon><IconPlus /></template>
</OsButton>
<OsButton circle size="sm" variant="default" aria-label="Add">
<template #icon><PlusIcon /></template>
<template #icon><IconPlus /></template>
</OsButton>
</div>
</div>
@ -565,13 +541,13 @@ export const CircleSizes: Story = {
<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>
<template #icon><IconPlus /></template>
</OsButton>
<OsButton circle size="md" variant="danger" aria-label="Add">
<template #icon><PlusIcon /></template>
<template #icon><IconPlus /></template>
</OsButton>
<OsButton circle size="md" variant="default" aria-label="Add">
<template #icon><PlusIcon /></template>
<template #icon><IconPlus /></template>
</OsButton>
</div>
</div>
@ -579,13 +555,13 @@ export const CircleSizes: Story = {
<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>
<template #icon><IconPlus /></template>
</OsButton>
<OsButton circle size="lg" variant="danger" aria-label="Add">
<template #icon><PlusIcon /></template>
<template #icon><IconPlus /></template>
</OsButton>
<OsButton circle size="lg" variant="default" aria-label="Add">
<template #icon><PlusIcon /></template>
<template #icon><IconPlus /></template>
</OsButton>
</div>
</div>
@ -593,13 +569,13 @@ export const CircleSizes: Story = {
<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>
<template #icon><IconPlus /></template>
</OsButton>
<OsButton circle size="xl" variant="danger" aria-label="Add">
<template #icon><PlusIcon /></template>
<template #icon><IconPlus /></template>
</OsButton>
<OsButton circle size="xl" variant="default" aria-label="Add">
<template #icon><PlusIcon /></template>
<template #icon><IconPlus /></template>
</OsButton>
</div>
</div>
@ -610,23 +586,23 @@ export const CircleSizes: Story = {
export const CircleAppearances: Story = {
render: () => ({
components: { OsButton, PlusIcon, CloseIcon, CheckIcon },
components: { OsButton, IconPlus, IconClose, IconCheck },
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>
<template #icon><IconPlus /></template>
</OsButton>
<OsButton circle appearance="filled" variant="danger" aria-label="Close">
<template #icon><CloseIcon /></template>
<template #icon><IconClose /></template>
</OsButton>
<OsButton circle appearance="filled" variant="success" aria-label="Confirm">
<template #icon><CheckIcon /></template>
<template #icon><IconCheck /></template>
</OsButton>
<OsButton circle appearance="filled" variant="default" aria-label="Add">
<template #icon><PlusIcon /></template>
<template #icon><IconPlus /></template>
</OsButton>
</div>
</div>
@ -634,16 +610,16 @@ export const CircleAppearances: Story = {
<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>
<template #icon><IconPlus /></template>
</OsButton>
<OsButton circle appearance="outline" variant="danger" aria-label="Close">
<template #icon><CloseIcon /></template>
<template #icon><IconClose /></template>
</OsButton>
<OsButton circle appearance="outline" variant="success" aria-label="Confirm">
<template #icon><CheckIcon /></template>
<template #icon><IconCheck /></template>
</OsButton>
<OsButton circle appearance="outline" variant="default" aria-label="Add">
<template #icon><PlusIcon /></template>
<template #icon><IconPlus /></template>
</OsButton>
</div>
</div>
@ -651,16 +627,16 @@ export const CircleAppearances: Story = {
<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>
<template #icon><IconPlus /></template>
</OsButton>
<OsButton circle appearance="ghost" variant="danger" aria-label="Close">
<template #icon><CloseIcon /></template>
<template #icon><IconClose /></template>
</OsButton>
<OsButton circle appearance="ghost" variant="success" aria-label="Confirm">
<template #icon><CheckIcon /></template>
<template #icon><IconCheck /></template>
</OsButton>
<OsButton circle appearance="ghost" variant="default" aria-label="Add">
<template #icon><PlusIcon /></template>
<template #icon><IconPlus /></template>
</OsButton>
</div>
</div>
@ -671,7 +647,7 @@ export const CircleAppearances: Story = {
export const Polymorphic: Story = {
render: () => ({
components: { OsButton, CheckIcon, PlusIcon },
components: { OsButton, IconCheck, IconPlus },
template: `
<div class="flex flex-col gap-4">
<div>
@ -687,11 +663,11 @@ export const Polymorphic: Story = {
<h3 class="text-sm font-bold mb-2">as="a" with icon</h3>
<div class="flex flex-wrap gap-2">
<OsButton as="a" href="#" variant="primary">
<template #icon><CheckIcon /></template>
<template #icon><IconCheck /></template>
Save
</OsButton>
<OsButton as="a" href="#" variant="success" circle aria-label="Add">
<template #icon><PlusIcon /></template>
<template #icon><IconPlus /></template>
</OsButton>
</div>
</div>
@ -709,7 +685,7 @@ export const Polymorphic: Story = {
export const Loading: Story = {
render: () => ({
components: { OsButton, CheckIcon },
components: { OsButton, IconCheck },
template: `
<div class="flex flex-col gap-4">
<div>
@ -734,12 +710,12 @@ export const Loading: Story = {
<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>
<template #icon><IconCheck /></template>
Save
</OsButton>
<OsButton loading variant="danger">Delete</OsButton>
<OsButton loading circle variant="primary" aria-label="Loading">
<template #icon><CheckIcon /></template>
<template #icon><IconCheck /></template>
</OsButton>
</div>
</div>

View File

@ -0,0 +1,225 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { defineComponent, h, markRaw } from 'vue'
import { ICON_SIZES } from './icon.variants'
import { IconCheck, IconClose, IconPlus, SYSTEM_ICONS } from './icons'
import OsIcon from './OsIcon.vue'
import type { Size } from '#src/types'
describe('osIcon', () => {
describe('rendering', () => {
it('renders a system icon by name', () => {
const wrapper = mount(OsIcon, {
props: { name: 'close' },
})
expect(wrapper.find('.os-icon').exists()).toBe(true)
expect(wrapper.find('svg').exists()).toBe(true)
})
it('renders a custom render function via icon prop', () => {
const CustomIcon = () => h('svg', { 'data-testid': 'custom' })
const wrapper = mount(OsIcon, {
props: { icon: CustomIcon },
})
expect(wrapper.find('.os-icon').exists()).toBe(true)
expect(wrapper.find('[data-testid="custom"]').exists()).toBe(true)
})
it('renders a custom component object via icon prop', () => {
const CustomIcon = markRaw(
defineComponent({
render: () => h('svg', { 'data-testid': 'component' }),
}),
)
const wrapper = mount(OsIcon, {
props: { icon: CustomIcon },
})
expect(wrapper.find('.os-icon').exists()).toBe(true)
expect(wrapper.find('[data-testid="component"]').exists()).toBe(true)
})
it('renders nothing when neither name nor icon is provided', () => {
const wrapper = mount(OsIcon)
expect(wrapper.find('.os-icon').exists()).toBe(false)
expect(wrapper.html()).toBe('')
})
it('icon prop takes precedence over name', () => {
const CustomIcon = () => h('svg', { 'data-testid': 'custom' })
const wrapper = mount(OsIcon, {
props: { name: 'close', icon: CustomIcon },
})
expect(wrapper.find('[data-testid="custom"]').exists()).toBe(true)
})
it('renders nothing for unknown icon name', () => {
const wrapper = mount(OsIcon, {
props: { name: 'nonexistent' },
})
expect(wrapper.find('.os-icon').exists()).toBe(false)
})
})
describe('size prop', () => {
const sizes = Object.entries(ICON_SIZES)
it.each(sizes)('applies %s size class', (size, expectedClass) => {
const wrapper = mount(OsIcon, {
props: { name: 'close', size: size as Size },
})
expect(wrapper.classes()).toContain(expectedClass)
})
it('defaults to md size', () => {
const wrapper = mount(OsIcon, {
props: { name: 'close' },
})
expect(wrapper.classes()).toContain('h-[1.2em]')
})
})
describe('accessibility', () => {
it('has aria-hidden="true" by default (decorative)', () => {
const wrapper = mount(OsIcon, {
props: { name: 'close' },
})
expect(wrapper.attributes('aria-hidden')).toBe('true')
})
it('does not have role="img" by default', () => {
const wrapper = mount(OsIcon, {
props: { name: 'close' },
})
expect(wrapper.attributes('role')).toBeUndefined()
})
it('has role="img" when aria-label is provided', () => {
const wrapper = mount(OsIcon, {
props: { name: 'close' },
attrs: { 'aria-label': 'Close' },
})
expect(wrapper.attributes('role')).toBe('img')
expect(wrapper.attributes('aria-label')).toBe('Close')
})
it('does not have aria-hidden when aria-label is provided', () => {
const wrapper = mount(OsIcon, {
props: { name: 'close' },
attrs: { 'aria-label': 'Close' },
})
expect(wrapper.attributes('aria-hidden')).toBeUndefined()
})
})
describe('css', () => {
it('has fill-current class for color inheritance', () => {
const wrapper = mount(OsIcon, {
props: { name: 'close' },
})
expect(wrapper.classes()).toContain('[&>svg]:fill-current')
})
it('has inline-flex display', () => {
const wrapper = mount(OsIcon, {
props: { name: 'close' },
})
expect(wrapper.classes()).toContain('inline-flex')
})
it('merges custom classes', () => {
const wrapper = mount(OsIcon, {
props: { name: 'close' },
attrs: { class: 'text-red-500' },
})
expect(wrapper.classes()).toContain('text-red-500')
expect(wrapper.classes()).toContain('os-icon')
})
it('renders as span element', () => {
const wrapper = mount(OsIcon, {
props: { name: 'close' },
})
expect((wrapper.element as HTMLElement).tagName).toBe('SPAN')
})
it('has shrink-0 class', () => {
const wrapper = mount(OsIcon, {
props: { name: 'close' },
})
expect(wrapper.classes()).toContain('shrink-0')
})
})
describe('system icons', () => {
const iconEntries = Object.entries(SYSTEM_ICONS)
it('has 3 system icons registered', () => {
expect(iconEntries).toHaveLength(3)
})
it.each(iconEntries)('renders "%s" without errors', (name) => {
const wrapper = mount(OsIcon, {
props: { name },
})
expect(wrapper.find('svg').exists()).toBe(true)
expect(wrapper.find('path').exists()).toBe(true)
})
it('exports all icon components individually and renders them', () => {
const icons = [IconCheck, IconClose, IconPlus]
for (const icon of icons) {
const wrapper = mount(OsIcon, { props: { icon } })
expect(wrapper.find('svg').exists()).toBe(true)
}
})
it('each icon SVG has viewBox attribute', () => {
for (const name of Object.keys(SYSTEM_ICONS)) {
const wrapper = mount(OsIcon, { props: { name } })
const svg = wrapper.find('svg')
expect(svg.attributes('viewBox'), `${name} should have viewBox`).toBe('0 0 32 32')
}
})
})
describe('keyboard accessibility', () => {
it('is not focusable (decorative element)', () => {
const wrapper = mount(OsIcon, {
props: { name: 'close' },
})
expect(wrapper.attributes('tabindex')).toBeUndefined()
})
it('renders as span element (not interactive)', () => {
const wrapper = mount(OsIcon, {
props: { name: 'close' },
})
expect((wrapper.element as HTMLElement).tagName).toBe('SPAN')
})
})
})

View File

@ -0,0 +1,143 @@
import { computed, h } from 'vue'
import { SYSTEM_ICONS } from './icons'
import OsIcon from './OsIcon.vue'
import type { Meta, StoryObj } from '@storybook/vue3-vite'
const meta: Meta<typeof OsIcon> = {
title: 'Components/OsIcon',
component: OsIcon,
tags: ['autodocs'],
}
export default meta
type Story = StoryObj<typeof OsIcon>
const iconNames = Object.keys(SYSTEM_ICONS)
const HeartIcon = () =>
h('svg', { xmlns: 'http://www.w3.org/2000/svg', viewBox: '0 0 20 20', fill: 'currentColor' }, [
h('path', {
d: 'M9.653 16.915l-.005-.003-.019-.01a20.759 20.759 0 01-1.162-.682 22.045 22.045 0 01-2.837-2.194C3.614 12.186 2 10.114 2 7.5A4.5 4.5 0 016.5 3c1.279 0 2.374.612 3.149 1.469C10.376 3.612 11.471 3 12.75 3A4.5 4.5 0 0117.25 7.5c0 2.614-1.614 4.686-3.63 6.526a22.045 22.045 0 01-2.837 2.194 20.759 20.759 0 01-1.162.682l-.019.01-.005.003h-.002L9.653 16.915z',
}),
])
interface PlaygroundArgs {
name: string
size: string
ariaLabel: string
}
export const Playground: StoryObj<PlaygroundArgs> = {
argTypes: {
name: {
control: 'select',
options: iconNames,
},
size: {
control: 'select',
options: ['xs', 'sm', 'md', 'lg', 'xl', '2xl'],
},
ariaLabel: {
control: 'text',
},
},
args: {
name: 'close',
size: 'md',
ariaLabel: '',
},
render: (args) => ({
components: { OsIcon },
setup() {
const iconProps = computed(() => ({
name: args.name,
size: args.size,
}))
const ariaLabel = computed(() => args.ariaLabel || undefined)
return { iconProps, ariaLabel }
},
template: `<OsIcon v-bind="iconProps" :aria-label="ariaLabel" />`,
}),
}
export const AllSystemIcons: Story = {
render: () => ({
components: { OsIcon },
setup() {
return { iconNames }
},
template: `
<div class="grid grid-cols-5 gap-4">
<div v-for="name in iconNames" :key="name" class="flex flex-col items-center gap-2 p-3 rounded border border-gray-200">
<OsIcon :name="name" size="xl" />
<span class="text-xs text-gray-600">{{ name }}</span>
</div>
</div>
`,
}),
}
export const AllSizes: Story = {
render: () => ({
components: { OsIcon },
template: `
<div data-testid="all-sizes" class="flex items-end gap-4">
<div class="flex flex-col items-center gap-1">
<OsIcon name="check" size="xs" />
<span class="text-xs text-gray-500">xs</span>
</div>
<div class="flex flex-col items-center gap-1">
<OsIcon name="check" size="sm" />
<span class="text-xs text-gray-500">sm</span>
</div>
<div class="flex flex-col items-center gap-1">
<OsIcon name="check" size="md" />
<span class="text-xs text-gray-500">md</span>
</div>
<div class="flex flex-col items-center gap-1">
<OsIcon name="check" size="lg" />
<span class="text-xs text-gray-500">lg</span>
</div>
<div class="flex flex-col items-center gap-1">
<OsIcon name="check" size="xl" />
<span class="text-xs text-gray-500">xl</span>
</div>
<div class="flex flex-col items-center gap-1">
<OsIcon name="check" size="2xl" />
<span class="text-xs text-gray-500">2xl</span>
</div>
</div>
`,
}),
}
export const CustomComponent: Story = {
render: () => ({
components: { OsIcon },
setup() {
return { HeartIcon }
},
template: `
<div data-testid="custom-component" class="flex items-center gap-4">
<OsIcon :icon="HeartIcon" size="md" />
<OsIcon :icon="HeartIcon" size="lg" />
<OsIcon :icon="HeartIcon" size="xl" />
</div>
`,
}),
}
export const InheritColor: Story = {
render: () => ({
components: { OsIcon },
template: `
<div data-testid="inherit-color" class="flex items-center gap-4 text-lg">
<span class="text-red-500"><OsIcon name="close" /></span>
<span class="text-green-500"><OsIcon name="check" /></span>
<span class="text-blue-500"><OsIcon name="plus" /></span>
</div>
`,
}),
}

View File

@ -0,0 +1,100 @@
import { AxeBuilder } from '@axe-core/playwright'
import { expect, test } from '@playwright/test'
import type { Page } from '@playwright/test'
/**
* Visual regression tests for OsIcon component
*
* These tests capture screenshots of Storybook stories and compare them
* against baseline images to detect unintended visual changes.
* Each test also runs accessibility checks using axe-core.
*/
const STORY_URL = '/iframe.html?id=components-osicon'
const STORY_ROOT = '#storybook-root'
/**
* Wait for all fonts to be loaded before taking screenshots
*/
async function waitForFonts(page: Page) {
await page.evaluate(async () => document.fonts.ready)
}
/**
* Helper to run accessibility check on the current page
*/
async function checkA11y(page: Page) {
const results = await new AxeBuilder({ page }).include(STORY_ROOT).analyze()
expect(results.violations).toEqual([])
}
test.describe('OsIcon keyboard accessibility', () => {
test('decorative icons are not focusable', async ({ page }) => {
await page.goto(`${STORY_URL}--all-system-icons&viewMode=story`)
const root = page.locator(STORY_ROOT)
await root.waitFor()
const icons = root.locator('.os-icon')
const count = await icons.count()
expect(count).toBeGreaterThan(0)
for (let i = 0; i < count; i++) {
const icon = icons.nth(i)
await expect(icon).toHaveAttribute('aria-hidden', 'true')
}
})
})
test.describe('OsIcon visual regression', () => {
test('all system icons', async ({ page }) => {
await page.goto(`${STORY_URL}--all-system-icons&viewMode=story`)
const root = page.locator(STORY_ROOT)
await root.waitFor()
await waitForFonts(page)
await expect(root.locator('.grid')).toHaveScreenshot('all-system-icons.png')
await checkA11y(page)
})
test('all sizes', async ({ page }) => {
await page.goto(`${STORY_URL}--all-sizes&viewMode=story`)
const root = page.locator(STORY_ROOT)
await root.waitFor()
await waitForFonts(page)
await expect(root.locator('[data-testid="all-sizes"]')).toHaveScreenshot('all-sizes.png')
await checkA11y(page)
})
test('custom component', async ({ page }) => {
await page.goto(`${STORY_URL}--custom-component&viewMode=story`)
const root = page.locator(STORY_ROOT)
await root.waitFor()
await waitForFonts(page)
await expect(root.locator('[data-testid="custom-component"]')).toHaveScreenshot(
'custom-component.png',
)
await checkA11y(page)
})
test('inherit color', async ({ page }) => {
await page.goto(`${STORY_URL}--inherit-color&viewMode=story`)
const root = page.locator(STORY_ROOT)
await root.waitFor()
await waitForFonts(page)
await expect(root.locator('[data-testid="inherit-color"]')).toHaveScreenshot(
'inherit-color.png',
)
await checkA11y(page)
})
})

View File

@ -0,0 +1,119 @@
<script lang="ts">
import { defineComponent, getCurrentInstance, h, isVue2 } from 'vue-demi'
import { cn } from '#src/utils'
import { ICON_SIZES } from './icon.variants'
import { SYSTEM_ICONS } from './icons'
import type { Size } from '#src/types'
import type { SystemIconName } from './icons'
import type { ClassValue } from 'clsx'
import type { Component, PropType } from 'vue-demi'
/**
* Renders system icons by name or custom Vue components.
* @prop name - System icon name (e.g. 'close', 'check')
* @prop icon - Vue component to render as icon (takes precedence over name)
* @prop size - Icon size (xs/sm/md/lg/xl/2xl)
*/
export default defineComponent({
name: 'OsIcon',
inheritAttrs: false,
props: {
name: {
type: String,
default: undefined,
},
icon: {
type: [Object, Function] as PropType<Component>,
default: undefined,
},
size: {
type: String as PropType<Size>,
default: 'md',
},
},
setup(props, { attrs }) {
/* v8 ignore start -- Vue 2 only */
const instance = isVue2 ? getCurrentInstance() : null
/* v8 ignore stop */
return () => {
// icon prop takes precedence over name
const iconComponent =
props.icon || (props.name ? SYSTEM_ICONS[props.name as SystemIconName] : undefined)
if (!iconComponent) return null
const sizeClass = ICON_SIZES[props.size]
// Vue 2's h() cannot handle plain arrow functions as components (only
// constructor functions or option objects). SYSTEM_ICONS entries are
// arrow functions that return VNodes, so call them directly.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const isRenderFn = typeof iconComponent === 'function' && !(iconComponent as any).cid
const iconVNode = isRenderFn
? (iconComponent as () => ReturnType<typeof h>)()
: h(iconComponent)
/* v8 ignore start -- Vue 2 branch tested in webapp Jest tests */
if (isVue2) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const proxy = instance?.proxy as any
const parentClass = proxy?.$vnode?.data?.staticClass || ''
const parentDynClass = proxy?.$vnode?.data?.class
const parentAttrs = proxy?.$vnode?.data?.attrs || {}
const hasLabel = !!(parentAttrs['aria-label'] || attrs['aria-label'])
const a11yAttrs = hasLabel
? { role: 'img', 'aria-label': parentAttrs['aria-label'] || attrs['aria-label'] }
: { 'aria-hidden': 'true' }
return h(
'span',
{
class: [
cn(
'os-icon inline-flex items-center shrink-0',
sizeClass,
'[&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current',
),
parentClass,
parentDynClass,
].filter(Boolean),
attrs: { ...a11yAttrs, ...parentAttrs, ...attrs },
},
[iconVNode],
)
}
/* v8 ignore stop */
const {
class: attrClass,
'aria-label': ariaLabel,
...restAttrs
} = attrs as Record<string, unknown>
const hasLabel = !!ariaLabel
const a11yAttrs = hasLabel
? { role: 'img', 'aria-label': ariaLabel }
: { 'aria-hidden': 'true' }
return h(
'span',
{
class: cn(
'os-icon inline-flex items-center shrink-0',
sizeClass,
'[&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current',
attrClass as ClassValue,
),
...a11yAttrs,
...restAttrs,
},
[iconVNode],
)
}
},
})
</script>

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@ -0,0 +1,10 @@
import type { Size } from '#src/types'
export const ICON_SIZES: Record<Size, string> = {
xs: 'h-[0.75em]',
sm: 'h-[0.875em]',
md: 'h-[1.2em]',
lg: 'h-[1.5em]',
xl: 'h-[2em]',
'2xl': 'h-[2.5em]',
}

View File

@ -0,0 +1,17 @@
import _IconCheck from './svgs/check.svg?icon'
import _IconClose from './svgs/close.svg?icon'
import _IconPlus from './svgs/plus.svg?icon'
import type { Component } from 'vue-demi'
export const IconCheck = _IconCheck
export const IconClose = _IconClose
export const IconPlus = _IconPlus
export const SYSTEM_ICONS = {
check: IconCheck,
close: IconClose,
plus: IconPlus,
} as const satisfies Record<string, Component>
export type SystemIconName = keyof typeof SYSTEM_ICONS

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<path d="M28.281 6.281l1.438 1.438-18 18-0.719 0.688-0.719-0.688-8-8 1.438-1.438 7.281 7.281z"/>
</svg>

After

Width:  |  Height:  |  Size: 165 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<path d="M7.219 5.781l8.781 8.781 8.781-8.781 1.438 1.438-8.781 8.781 8.781 8.781-1.438 1.438-8.781-8.781-8.781 8.781-1.438-1.438 8.781-8.781-8.781-8.781z"/>
</svg>

After

Width:  |  Height:  |  Size: 226 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<path d="M15 5h2v10h10v2h-10v10h-2v-10h-10v-2h10v-10z"/>
</svg>

After

Width:  |  Height:  |  Size: 125 B

View File

@ -0,0 +1,3 @@
export { default as OsIcon } from './OsIcon.vue'
export { ICON_SIZES } from './icon.variants'
export { IconCheck, IconClose, IconPlus, SYSTEM_ICONS, type SystemIconName } from './icons'

View File

@ -8,3 +8,12 @@
*/
export { OsButton, buttonVariants, type ButtonVariants } from './OsButton'
export {
OsIcon,
ICON_SIZES,
IconCheck,
IconClose,
IconPlus,
SYSTEM_ICONS,
type SystemIconName,
} from './OsIcon'

View File

@ -0,0 +1,46 @@
import { OsIcon, SYSTEM_ICONS } from '#src/components/OsIcon'
import { ocelotIcons } from './index'
import type { Meta, StoryObj } from '@storybook/vue3-vite'
const meta: Meta = {
title: 'Ocelot/Icons',
tags: ['autodocs'],
}
export default meta
const systemEntries = Object.entries(SYSTEM_ICONS)
const ocelotEntries = Object.entries(ocelotIcons)
export const AllIcons: StoryObj = {
render: () => ({
components: { OsIcon },
setup() {
return { systemEntries, ocelotEntries }
},
template: `
<div data-testid="icon-gallery" class="flex flex-col gap-6">
<div>
<h3 class="text-sm font-bold mb-2">Library Icons</h3>
<div class="grid grid-cols-5 gap-4">
<div v-for="[name, icon] in systemEntries" :key="name" class="flex flex-col items-center gap-2 p-3 rounded border border-gray-200">
<OsIcon :icon="icon" size="xl" />
<span class="text-xs text-gray-600">{{ name }}</span>
</div>
</div>
</div>
<div>
<h3 class="text-sm font-bold mb-2">Ocelot Icons</h3>
<div class="grid grid-cols-5 gap-4">
<div v-for="[name, icon] in ocelotEntries" :key="name" class="flex flex-col items-center gap-2 p-3 rounded border border-gray-200">
<OsIcon :icon="icon" size="xl" />
<span class="text-xs text-gray-600">{{ name }}</span>
</div>
</div>
</div>
</div>
`,
}),
}

View File

@ -0,0 +1,73 @@
import { AxeBuilder } from '@axe-core/playwright'
import { expect, test } from '@playwright/test'
import type { Page } from '@playwright/test'
/**
* Visual regression tests for Ocelot Icons
*
* These tests capture screenshots of Storybook stories and compare them
* against baseline images to detect unintended visual changes.
* Each test also runs accessibility checks using axe-core.
*/
const STORY_URL = '/iframe.html?id=ocelot-icons'
const STORY_ROOT = '#storybook-root'
/**
* Wait for all fonts to be loaded before taking screenshots
*/
async function waitForFonts(page: Page) {
await page.evaluate(async () => document.fonts.ready)
}
/**
* Helper to run accessibility check on the current page
*/
async function checkA11y(page: Page) {
const results = await new AxeBuilder({ page }).include(STORY_ROOT).analyze()
expect(results.violations).toEqual([])
}
test.describe('OcelotIcons keyboard accessibility', () => {
test('all icons are decorative (not focusable)', async ({ page }) => {
await page.goto(`${STORY_URL}--all-icons&viewMode=story`)
const root = page.locator(STORY_ROOT)
await root.waitFor()
const icons = root.locator('.os-icon')
const count = await icons.count()
expect(count).toBeGreaterThan(0)
for (let i = 0; i < count; i++) {
const icon = icons.nth(i)
await expect(icon).toHaveAttribute('aria-hidden', 'true')
const tabindex = await icon.getAttribute('tabindex')
expect(tabindex, `icon ${String(i)} should not have tabindex`).toBeNull()
}
// Verify no icon receives focus when tabbing through
await page.keyboard.press('Tab')
for (let i = 0; i < count; i++) {
const icon = icons.nth(i)
await expect(icon).not.toBeFocused()
}
})
})
test.describe('OcelotIcons visual regression', () => {
test('all icons', async ({ page }) => {
await page.goto(`${STORY_URL}--all-icons&viewMode=story`)
const root = page.locator(STORY_ROOT)
await root.waitFor()
await waitForFonts(page)
await expect(root.locator('[data-testid="icon-gallery"]')).toHaveScreenshot('all-icons.png')
await checkA11y(page)
})
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

View File

@ -0,0 +1,62 @@
import { mount } from '@vue/test-utils'
import { describe, expect, expectTypeOf, it } from 'vitest'
import { h } from 'vue'
import OsIcon from '#src/components/OsIcon/OsIcon.vue'
import { ocelotIcons } from './index'
describe('ocelot icons', () => {
const IconAngleDown = ocelotIcons.IconAngleDown
describe('exports', () => {
it('exports ocelotIcons as a record of functions', () => {
expectTypeOf(ocelotIcons).toBeObject()
expect(Object.keys(ocelotIcons).length).toBeGreaterThan(0)
})
it('auto-discovers all SVGs in svgs/ directory', () => {
expect(ocelotIcons).toHaveProperty('IconAngleDown')
expectTypeOf(IconAngleDown).toBeFunction()
})
})
describe('iconAngleDown', () => {
it('renders an SVG with correct viewBox', () => {
const vnode = IconAngleDown()
expect(vnode).toBeDefined()
const wrapper = mount({
render: () => h('div', [IconAngleDown()]),
})
const svg = wrapper.find('svg')
expect(svg.exists()).toBe(true)
expect(svg.attributes('viewBox')).toBe('0 0 32 32')
})
it('works with OsIcon :icon prop', () => {
const wrapper = mount(OsIcon, {
props: { icon: IconAngleDown },
})
expect(wrapper.find('.os-icon').exists()).toBe(true)
expect(wrapper.find('svg').exists()).toBe(true)
expect(wrapper.find('path').exists()).toBe(true)
})
})
describe('keyboard accessibility', () => {
it('icon via :icon prop is not focusable (decorative element)', () => {
const wrapper = mount(OsIcon, {
props: { icon: IconAngleDown },
})
expect(wrapper.attributes('tabindex')).toBeUndefined()
expect(wrapper.attributes('aria-hidden')).toBe('true')
})
})
})

View File

@ -0,0 +1,24 @@
import type { VNode } from 'vue-demi'
const modules = import.meta.glob<() => VNode>('./svgs/*.svg', {
query: '?icon',
eager: true,
import: 'default',
})
function toName(path: string): string {
return (
'Icon' +
path
.replace('./svgs/', '')
.replace('.svg', '')
.split('-')
.filter(Boolean)
.map((s) => s[0].toUpperCase() + s.slice(1))
.join('')
)
}
export const ocelotIcons: Record<string, () => VNode> = Object.fromEntries(
Object.entries(modules).map(([path, icon]) => [toName(path), icon]),
)

View File

@ -0,0 +1,5 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<title>angle-down</title>
<path d="M4.219 10.781l11.781 11.781 11.781-11.781 1.438 1.438-12.5 12.5-0.719 0.688-0.719-0.688-12.5-12.5z"></path>
</svg>

After

Width:  |  Height:  |  Size: 281 B

View File

@ -0,0 +1,2 @@
// Ocelot migration icons — temporary, will be removed after Vue 3 migration
export { ocelotIcons } from './icons'

View File

@ -0,0 +1,64 @@
import { readFile } from 'node:fs/promises'
import { resolve } from 'node:path'
import type { Plugin } from 'vite'
const SUFFIX = '?icon'
/** Escape a string for safe embedding in a single-quoted JS literal */
function escapeJS(str: string): string {
return str.replace(/\\/g, '\\\\').replace(/'/g, "\\'")
}
export default function svgIcon(): Plugin {
return {
name: 'svg-icon',
enforce: 'pre',
resolveId(source, importer) {
if (!source.endsWith(SUFFIX)) return null
const svgPath = source.slice(0, -SUFFIX.length)
const resolved = importer ? resolve(importer, '..', svgPath) : svgPath
return `\0svg-icon:${resolved}`
},
async load(id) {
if (!id.startsWith('\0svg-icon:')) return null
const filePath = id.slice('\0svg-icon:'.length)
// eslint-disable-next-line security/detect-non-literal-fs-filename -- resolved from Vite import graph
const svg = await readFile(filePath, 'utf-8')
const viewBoxRegex = /viewBox="([^"]+)"/
const viewBoxMatch = viewBoxRegex.exec(svg)
const viewBox = viewBoxMatch ? viewBoxMatch[1] : '0 0 32 32'
const unsupported = svg.match(/<(?:circle|rect|polygon|polyline|ellipse|line)\s/g)
if (unsupported) {
this.warn(
`${filePath}: unsupported SVG elements will be ignored: ${[...new Set(unsupported.map((s) => s.trim()))].join(', ')}`,
)
}
const paths: string[] = []
const pathRegex = /<path\s[^>]*?\bd="([^"]+)"/g
let match: RegExpExecArray | null
while ((match = pathRegex.exec(svg)) !== null) {
paths.push(match[1])
}
const pathElements = paths
.map((d) => {
const escaped = escapeJS(d)
return `h('path', isVue2 ? { attrs: { d: '${escaped}' } } : { d: '${escaped}' })`
})
.join(', ')
const safeViewBox = escapeJS(viewBox)
return `import { h, isVue2 } from 'vue-demi'
const svgAttrs = { xmlns: 'http://www.w3.org/2000/svg', viewBox: '${safeViewBox}', fill: 'currentColor' }
export default () => h('svg', isVue2 ? { attrs: svgAttrs } : svgAttrs, [${pathElements}])
`
},
}
}

View File

@ -13,6 +13,7 @@
/* Scan component files for utility classes */
@source "../components/**/*.vue";
@source "../ocelot/**/*.vue";
@source "../**/*.ts";
/*

6
packages/ui/src/svg-icon.d.ts vendored Normal file
View File

@ -0,0 +1,6 @@
declare module '*.svg?icon' {
import type { VNode } from 'vue-demi'
const icon: () => VNode
export default icon
}

View File

@ -9,6 +9,8 @@ import dts from 'vite-plugin-dts'
import tsconfigPaths from 'vite-tsconfig-paths'
import { defineConfig } from 'vitest/config'
import svgIcon from './src/plugins/vite-svg-icon'
const execAsync = promisify(exec)
export default defineConfig({
@ -16,6 +18,7 @@ export default defineConfig({
exclude: ['vue-demi'],
},
plugins: [
svgIcon(),
vue(),
tailwindcss(),
tsconfigPaths(),
@ -28,6 +31,7 @@ export default defineConfig({
// Generate .d.cts files for CJS compatibility
await copyFile('dist/index.d.ts', 'dist/index.d.cts')
await copyFile('dist/tailwind.preset.d.ts', 'dist/tailwind.preset.d.cts')
await copyFile('dist/ocelot.d.ts', 'dist/ocelot.d.cts')
},
}),
// Build CSS separately using Tailwind CLI
@ -43,6 +47,7 @@ export default defineConfig({
entry: {
index: resolve(__dirname, 'src/index.ts'),
'tailwind.preset': resolve(__dirname, 'src/tailwind.preset.ts'),
ocelot: resolve(__dirname, 'src/ocelot/index.ts'),
},
formats: ['es', 'cjs'],
fileName: (format, entryName) => `${entryName}.${format === 'es' ? 'mjs' : 'cjs'}`,
@ -64,7 +69,7 @@ export default defineConfig({
globals: true,
environment: 'jsdom',
include: ['src/**/*.{test,spec}.{js,ts}'],
exclude: ['src/**/*.visual.spec.ts'],
exclude: ['src/**/*.visual.spec.ts', 'src/plugins/**'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'json-summary', 'html'],
@ -74,6 +79,7 @@ export default defineConfig({
'src/**/*.{test,spec}.ts',
'src/**/*.stories.ts',
'src/**/index.ts',
'src/plugins/**',
],
thresholds: {
100: true,

View File

@ -20,7 +20,7 @@
placement: 'bottom-start',
}"
/>
<base-icon class="dropdown-arrow" name="angle-down" />
<os-icon class="dropdown-arrow" :icon="ocelotIcons.IconAngleDown" />
</a>
</template>
<template #popover="{ closeMenu }">
@ -59,6 +59,8 @@
</template>
<script>
import { OsIcon } from '@ocelot-social/ui'
import { ocelotIcons } from '@ocelot-social/ui/ocelot'
import { mapGetters } from 'vuex'
import Dropdown from '~/components/Dropdown'
import ProfileAvatar from '~/components/_new/generic/ProfileAvatar/ProfileAvatar'
@ -66,11 +68,15 @@ import ProfileAvatar from '~/components/_new/generic/ProfileAvatar/ProfileAvatar
export default {
components: {
Dropdown,
OsIcon,
ProfileAvatar,
},
props: {
placement: { type: String, default: 'top-end' },
},
setup() {
return { ocelotIcons }
},
computed: {
...mapGetters({
user: 'auth/user',

View File

@ -12,7 +12,7 @@
@click="closeUserSearch"
>
<template #icon>
<base-icon name="close" />
<os-icon name="close" />
</template>
</os-button>
</ds-flex>
@ -24,13 +24,14 @@
</template>
<script>
import { OsButton } from '@ocelot-social/ui'
import { OsButton, OsIcon } from '@ocelot-social/ui'
import SelectUserSearch from '~/components/generic/SelectUserSearch/SelectUserSearch'
export default {
name: 'AddChatRoomByUserSearch',
components: {
OsButton,
OsIcon,
SelectUserSearch,
},
props: {

View File

@ -60,7 +60,7 @@
@click="$emit('close-single-room', true)"
>
<template #icon>
<base-icon name="close" />
<os-icon name="close" />
</template>
</os-button>
</slot>
@ -105,7 +105,7 @@
</template>
<script>
import { OsButton } from '@ocelot-social/ui'
import { OsButton, OsIcon } from '@ocelot-social/ui'
import { roomQuery, createRoom, unreadRoomsQuery } from '~/graphql/Rooms'
import {
messageQuery,
@ -118,7 +118,7 @@ import { mapGetters, mapMutations } from 'vuex'
export default {
name: 'Chat',
components: { OsButton },
components: { OsButton, OsIcon },
props: {
theme: {
type: String,

View File

@ -190,7 +190,7 @@
:disabled="!!errors"
>
<template #icon>
<base-icon name="check" />
<os-icon name="check" />
</template>
{{ $t('actions.save') }}
</os-button>
@ -202,7 +202,7 @@
</div>
</template>
<script>
import { OsButton } from '@ocelot-social/ui'
import { OsButton, OsIcon } from '@ocelot-social/ui'
import gql from 'graphql-tag'
import { mapGetters } from 'vuex'
import Editor from '~/components/Editor/Editor'
@ -223,6 +223,7 @@ export default {
Editor,
ImageUploader,
OsButton,
OsIcon,
PageParamsLink,
},
props: {

View File

@ -10,7 +10,7 @@
>
<base-icon name="filter" />
<label class="label" for="dropdown">{{ selected }}</label>
<base-icon class="dropdown-arrow" name="angle-down" />
<os-icon class="dropdown-arrow" :icon="ocelotIcons.IconAngleDown" />
</a>
</template>
<template #popover="{ toggleMenu }">
@ -30,11 +30,17 @@
</dropdown>
</template>
<script>
import { OsIcon } from '@ocelot-social/ui'
import { ocelotIcons } from '@ocelot-social/ui/ocelot'
import Dropdown from '~/components/Dropdown'
export default {
components: {
Dropdown,
OsIcon,
},
setup() {
return { ocelotIcons }
},
props: {
selected: { type: String, default: '' },

View File

@ -57,14 +57,14 @@
@click.prevent="removeEmbed()"
>
<template #icon>
<base-icon name="close" />
<os-icon name="close" />
</template>
</os-button>
</ds-container>
</template>
<script>
import { OsButton } from '@ocelot-social/ui'
import { OsButton, OsIcon } from '@ocelot-social/ui'
import { mapGetters, mapMutations } from 'vuex'
import { updateUserMutation } from '~/graphql/User.js'
@ -72,6 +72,7 @@ export default {
name: 'embed-component',
components: {
OsButton,
OsIcon,
},
props: {
dataEmbedUrl: {

View File

@ -9,7 +9,7 @@
@click="setResetCategories"
>
<template #icon>
<base-icon name="check" />
<os-icon name="check" />
</template>
{{ $t('filter-menu.all') }}
</os-button>
@ -39,7 +39,7 @@
</template>
<script>
import { OsButton } from '@ocelot-social/ui'
import { OsButton, OsIcon } from '@ocelot-social/ui'
import { mapGetters, mapMutations } from 'vuex'
import FilterMenuSection from '~/components/FilterMenu/FilterMenuSection'
import SortCategories from '~/mixins/sortCategoriesMixin.js'
@ -49,6 +49,7 @@ export default {
components: {
FilterMenuSection,
OsButton,
OsIcon,
},
mixins: [SortCategories, GetCategories],
computed: {

View File

@ -11,7 +11,7 @@
data-test="all-button"
>
<template #icon>
<base-icon name="check" />
<os-icon name="check" />
</template>
{{ $t('filter-menu.ended.all.label') }}
</os-button>
@ -36,7 +36,7 @@
</template>
<script>
import { OsButton } from '@ocelot-social/ui'
import { OsButton, OsIcon } from '@ocelot-social/ui'
import { mapGetters, mapMutations } from 'vuex'
import FilterMenuSection from '~/components/FilterMenu/FilterMenuSection'
@ -45,6 +45,7 @@ export default {
components: {
FilterMenuSection,
OsButton,
OsIcon,
},
computed: {
...mapGetters({

View File

@ -10,7 +10,7 @@
<template #icon>
<base-icon name="filter" />
</template>
<base-icon class="dropdown-arrow" name="angle-down" />
<os-icon class="dropdown-arrow" :icon="ocelotIcons.IconAngleDown" />
</os-button>
</template>
<template #popover>
@ -20,7 +20,8 @@
</template>
<script>
import { OsButton } from '@ocelot-social/ui'
import { OsButton, OsIcon } from '@ocelot-social/ui'
import { ocelotIcons } from '@ocelot-social/ui/ocelot'
import Dropdown from '~/components/Dropdown'
import { mapGetters } from 'vuex'
import FilterMenuComponent from './FilterMenuComponent'
@ -30,6 +31,10 @@ export default {
Dropdown,
FilterMenuComponent,
OsButton,
OsIcon,
},
setup() {
return { ocelotIcons }
},
props: {
placement: { type: String },

View File

@ -15,7 +15,7 @@
@click="setResetFollowers"
>
<template #icon>
<base-icon name="check" />
<os-icon name="check" />
</template>
{{ $t('filter-menu.all') }}
</os-button>
@ -55,7 +55,7 @@
</template>
<script>
import { OsButton } from '@ocelot-social/ui'
import { OsButton, OsIcon } from '@ocelot-social/ui'
import { mapGetters, mapMutations } from 'vuex'
import FilterMenuSection from '~/components/FilterMenu/FilterMenuSection'
@ -64,6 +64,7 @@ export default {
components: {
FilterMenuSection,
OsButton,
OsIcon,
},
computed: {
...mapGetters({

View File

@ -14,17 +14,17 @@
@click.stop="clickRemove"
>
<template #icon>
<base-icon name="close" />
<os-icon name="close" />
</template>
</os-button>
</span>
</template>
<script>
import { OsButton } from '@ocelot-social/ui'
import { OsButton, OsIcon } from '@ocelot-social/ui'
export default {
name: 'HeaderButton',
components: { OsButton },
components: { OsButton, OsIcon },
props: {
title: {
type: String,

View File

@ -11,17 +11,17 @@
@click="clearSearch"
>
<template #icon>
<base-icon name="close" />
<os-icon name="close" />
</template>
</os-button>
</base-card>
</template>
<script>
import { OsButton } from '@ocelot-social/ui'
import { OsButton, OsIcon } from '@ocelot-social/ui'
export default {
components: { OsButton },
components: { OsButton, OsIcon },
props: {
hashtag: {
type: String,

View File

@ -13,7 +13,7 @@
>
<!-- <base-icon name="globe" /> -->
<span class="label">{{ current.code.toUpperCase() }}</span>
<base-icon class="dropdown-arrow" name="angle-down" />
<os-icon class="dropdown-arrow" :icon="ocelotIcons.IconAngleDown" />
</a>
</template>
<template #popover="{ toggleMenu }">
@ -35,6 +35,8 @@
</template>
<script>
import { OsIcon } from '@ocelot-social/ui'
import { ocelotIcons } from '@ocelot-social/ui/ocelot'
import gql from 'graphql-tag'
import Dropdown from '~/components/Dropdown'
import find from 'lodash/find'
@ -45,6 +47,10 @@ import { mapGetters, mapMutations } from 'vuex'
export default {
components: {
Dropdown,
OsIcon,
},
setup() {
return { ocelotIcons }
},
props: {
placement: { type: String, default: 'bottom-start' },

View File

@ -31,7 +31,7 @@
<template #footer>
<os-button class="cancel" variant="primary" appearance="outline" @click="cancel">
<template #icon>
<base-icon name="close" />
<os-icon name="close" />
</template>
{{ $t('report.cancel') }}
</os-button>
@ -54,7 +54,7 @@
</template>
<script>
import { OsButton } from '@ocelot-social/ui'
import { OsButton, OsIcon } from '@ocelot-social/ui'
import { SweetalertIcon } from 'vue-sweetalert-icons'
import { reportMutation } from '~/graphql/Moderation.js'
import { valuesReasonCategoryOptions } from '~/constants/modals.js'
@ -64,6 +64,7 @@ export default {
name: 'ReportModal',
components: {
OsButton,
OsIcon,
SweetalertIcon,
},
props: {

View File

@ -23,20 +23,20 @@
style="right: -94%; top: -48px"
@click="clearLocationName"
>
<template #icon><base-icon name="close" /></template>
<template #icon><os-icon name="close" /></template>
</os-button>
</div>
</template>
<script>
import { OsButton } from '@ocelot-social/ui'
import { OsButton, OsIcon } from '@ocelot-social/ui'
import { queryLocations } from '~/graphql/location'
let timeout
export default {
name: 'LocationSelect',
components: { OsButton },
components: { OsButton, OsIcon },
props: {
value: {
required: true,

View File

@ -50,7 +50,7 @@
@click="closeCropper"
>
<template #icon>
<base-icon name="close" />
<os-icon name="close" />
</template>
</os-button>
</div>
@ -58,7 +58,7 @@
</template>
<script>
import { OsButton } from '@ocelot-social/ui'
import { OsButton, OsIcon } from '@ocelot-social/ui'
import Cropper from 'cropperjs'
import VueDropzone from 'nuxt-dropzone'
import LoadingSpinner from '~/components/_new/generic/LoadingSpinner/LoadingSpinner'
@ -70,6 +70,7 @@ export default {
components: {
LoadingSpinner,
OsButton,
OsIcon,
VueDropzone,
},
props: {

View File

@ -18,7 +18,7 @@
:disabled="disabled"
>
<template #icon>
<base-icon name="plus" />
<os-icon name="plus" />
</template>
</os-button>
</form>
@ -26,11 +26,11 @@
</template>
<script>
import { OsButton } from '@ocelot-social/ui'
import { OsButton, OsIcon } from '@ocelot-social/ui'
export default {
name: 'CreateInvitation',
components: { OsButton },
components: { OsButton, OsIcon },
props: {
disabled: {
type: Boolean,

View File

@ -64,9 +64,18 @@ exports[`CreateInvitation.vue renders 1`] = `
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"
aria-hidden="true"
class="os-icon inline-flex items-center shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current"
>
<!---->
<svg
fill="currentColor"
viewBox="0 0 32 32"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M15 5h2v10h10v2h-10v10h-2v-10h-10v-2h10v-10z"
/>
</svg>
</span>
</span>
</span>
@ -141,9 +150,18 @@ exports[`CreateInvitation.vue renders with disabled button 1`] = `
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"
aria-hidden="true"
class="os-icon inline-flex items-center shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current"
>
<!---->
<svg
fill="currentColor"
viewBox="0 0 32 32"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M15 5h2v10h10v2h-10v10h-2v-10h-10v-2h10v-10z"
/>
</svg>
</span>
</span>
</span>

View File

@ -236,9 +236,18 @@ exports[`InvitationList.vue renders 1`] = `
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"
aria-hidden="true"
class="os-icon inline-flex items-center shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current"
>
<!---->
<svg
fill="currentColor"
viewBox="0 0 32 32"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M15 5h2v10h10v2h-10v10h-2v-10h-10v-2h10v-10z"
/>
</svg>
</span>
</span>
</span>
@ -324,9 +333,18 @@ exports[`InvitationList.vue renders empty state 1`] = `
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"
aria-hidden="true"
class="os-icon inline-flex items-center shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current"
>
<!---->
<svg
fill="currentColor"
viewBox="0 0 32 32"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M15 5h2v10h10v2h-10v10h-2v-10h-10v-2h10v-10z"
/>
</svg>
</span>
</span>
</span>

View File

@ -59,14 +59,14 @@
@click="clear"
>
<template #icon>
<base-icon name="close" />
<os-icon name="close" />
</template>
</os-button>
</div>
</template>
<script>
import { OsButton } from '@ocelot-social/ui'
import { OsButton, OsIcon } from '@ocelot-social/ui'
import { isEmpty } from 'lodash'
import SearchHeading from '~/components/generic/SearchHeading/SearchHeading.vue'
import SearchPost from '~/components/generic/SearchPost/SearchPost.vue'
@ -78,6 +78,7 @@ export default {
name: 'SearchableInput',
components: {
OsButton,
OsIcon,
SearchHeading,
SearchGroup,
SearchPost,

View File

@ -47,6 +47,7 @@ module.exports = {
'^vue-demi$': path.resolve(__dirname, 'node_modules/vue-demi/lib/index.cjs'),
// UI library - use mock that loads dist with correct vue-demi
'^@ocelot-social/ui$': '<rootDir>/test/__mocks__/@ocelot-social/ui.js',
'^@ocelot-social/ui/ocelot$': '<rootDir>/test/__mocks__/@ocelot-social/ui/ocelot.js',
'^@ocelot-social/ui/style.css$': 'identity-obj-proxy',
// Other mappings
'\\.(svg)$': '<rootDir>/test/fileMock.js',

View File

@ -287,6 +287,7 @@ export default {
? '/packages/ui/dist'
: path.resolve(__dirname, '../packages/ui/dist')
config.resolve.alias['@ocelot-social/ui$'] = path.join(uiLibraryPath, 'index.mjs')
config.resolve.alias['@ocelot-social/ui/ocelot$'] = path.join(uiLibraryPath, 'ocelot.mjs')
config.resolve.alias['@ocelot-social/ui/style.css$'] = path.join(uiLibraryPath, 'style.css')
config.module.rules.push({
resourceQuery: /blockType=docs/,

View File

@ -948,9 +948,18 @@ exports[`GroupProfileSlug given a puplic group "yoga-practice" given a close
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"
aria-hidden="true"
class="os-icon inline-flex items-center shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current"
>
<!---->
<svg
fill="currentColor"
viewBox="0 0 32 32"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M15 5h2v10h10v2h-10v10h-2v-10h-10v-2h10v-10z"
/>
</svg>
</span>
</span>
</span>
@ -2940,9 +2949,18 @@ exports[`GroupProfileSlug given a puplic group "yoga-practice" given a close
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"
aria-hidden="true"
class="os-icon inline-flex items-center shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current"
>
<!---->
<svg
fill="currentColor"
viewBox="0 0 32 32"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M15 5h2v10h10v2h-10v10h-2v-10h-10v-2h10v-10z"
/>
</svg>
</span>
</span>
</span>
@ -3951,9 +3969,18 @@ exports[`GroupProfileSlug given a puplic group "yoga-practice" given a curre
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"
aria-hidden="true"
class="os-icon inline-flex items-center shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current"
>
<!---->
<svg
fill="currentColor"
viewBox="0 0 32 32"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M15 5h2v10h10v2h-10v10h-2v-10h-10v-2h10v-10z"
/>
</svg>
</span>
</span>
</span>
@ -6545,9 +6572,18 @@ exports[`GroupProfileSlug given a puplic group "yoga-practice" given a curre
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"
aria-hidden="true"
class="os-icon inline-flex items-center shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current"
>
<!---->
<svg
fill="currentColor"
viewBox="0 0 32 32"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M15 5h2v10h10v2h-10v10h-2v-10h-10v-2h10v-10z"
/>
</svg>
</span>
</span>
</span>
@ -7621,9 +7657,18 @@ exports[`GroupProfileSlug given a puplic group "yoga-practice" given a hidde
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"
aria-hidden="true"
class="os-icon inline-flex items-center shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current"
>
<!---->
<svg
fill="currentColor"
viewBox="0 0 32 32"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M15 5h2v10h10v2h-10v10h-2v-10h-10v-2h10v-10z"
/>
</svg>
</span>
</span>
</span>
@ -8638,9 +8683,18 @@ exports[`GroupProfileSlug given a puplic group "yoga-practice" given a hidde
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"
aria-hidden="true"
class="os-icon inline-flex items-center shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current"
>
<!---->
<svg
fill="currentColor"
viewBox="0 0 32 32"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M15 5h2v10h10v2h-10v10h-2v-10h-10v-2h10v-10z"
/>
</svg>
</span>
</span>
</span>

View File

@ -235,7 +235,7 @@
}"
>
<template #icon>
<base-icon name="plus" />
<os-icon name="plus" />
</template>
</os-button>
</ds-space>
@ -289,7 +289,7 @@
</template>
<script>
import { OsButton } from '@ocelot-social/ui'
import { OsButton, OsIcon } from '@ocelot-social/ui'
import uniqBy from 'lodash/uniqBy'
import { profilePagePosts } from '~/graphql/PostQuery'
import { updateGroupMutation, groupQuery, groupMembersQuery } from '~/graphql/groups'
@ -326,6 +326,7 @@ import GetCategories from '~/mixins/getCategoriesMixin.js'
export default {
components: {
OsButton,
OsIcon,
AvatarUploader,
Category,
ContentViewer,

View File

@ -170,9 +170,18 @@ exports[`invites.vue renders 1`] = `
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"
aria-hidden="true"
class="os-icon inline-flex items-center shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current"
>
<!---->
<svg
fill="currentColor"
viewBox="0 0 32 32"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M15 5h2v10h10v2h-10v10h-2v-10h-10v-2h10v-10z"
/>
</svg>
</span>
</span>
</span>

View File

@ -22,7 +22,7 @@
}"
>
<template #icon>
<base-icon name="plus" />
<os-icon name="plus" />
</template>
</os-button>
</ds-space>
@ -59,7 +59,7 @@
</template>
<script>
import { OsButton } from '@ocelot-social/ui'
import { OsButton, OsIcon } from '@ocelot-social/ui'
import GroupList from '~/components/Group/GroupList'
import { groupQuery, groupCountQuery } from '~/graphql/groups.js'
import TabNavigation from '~/components/_new/generic/TabNavigation/TabNavigation'
@ -76,6 +76,7 @@ export default {
name: 'Groups',
components: {
OsButton,
OsIcon,
GroupList,
TabNavigation,
PaginationButtons,

View File

@ -21,7 +21,7 @@
size="xl"
>
<template #icon>
<base-icon name="plus" />
<os-icon name="plus" />
</template>
</os-button>
</client-only>
@ -151,7 +151,7 @@
</template>
<script>
import { OsButton } from '@ocelot-social/ui'
import { OsButton, OsIcon } from '@ocelot-social/ui'
import postListActions from '~/mixins/postListActions'
import mobile from '~/mixins/mobile'
import DonationInfo from '~/components/DonationInfo/DonationInfo.vue'
@ -175,6 +175,7 @@ export default {
DonationInfo,
HashtagsFilter,
OsButton,
OsIcon,
PostTeaser,
HcEmpty,
MasonryGrid,

View File

@ -1836,9 +1836,18 @@ exports[`ProfileSlug given an authenticated user given the logged in user as pro
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"
aria-hidden="true"
class="os-icon inline-flex items-center shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current"
>
<!---->
<svg
fill="currentColor"
viewBox="0 0 32 32"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M15 5h2v10h10v2h-10v10h-2v-10h-10v-2h10v-10z"
/>
</svg>
</span>
</span>
</span>
@ -2471,9 +2480,18 @@ exports[`ProfileSlug given an authenticated user given the logged in user as pro
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"
aria-hidden="true"
class="os-icon inline-flex items-center shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current"
>
<!---->
<svg
fill="currentColor"
viewBox="0 0 32 32"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M15 5h2v10h10v2h-10v10h-2v-10h-10v-2h10v-10z"
/>
</svg>
</span>
</span>
</span>

View File

@ -161,7 +161,7 @@
:aria-label="$t('contribution.newPost')"
>
<template #icon>
<base-icon name="plus" />
<os-icon name="plus" />
</template>
</os-button>
</div>
@ -211,7 +211,7 @@
</template>
<script>
import { OsButton } from '@ocelot-social/ui'
import { OsButton, OsIcon } from '@ocelot-social/ui'
import uniqBy from 'lodash/uniqBy'
import { mapGetters, mapMutations } from 'vuex'
import postListActions from '~/mixins/postListActions'
@ -246,6 +246,7 @@ const tabToFilterMapping = ({ tab, id }) => {
export default {
components: {
OsButton,
OsIcon,
SocialMedia,
PostTeaser,
HcFollowButton,

View File

@ -36,7 +36,7 @@
:disabled="!!errors"
:loading="loadingData"
>
<template #icon><base-icon name="check" /></template>
<template #icon><os-icon name="check" /></template>
{{ $t('actions.save') }}
</os-button>
</base-card>
@ -45,7 +45,7 @@
</template>
<script>
import { OsButton } from '@ocelot-social/ui'
import { OsButton, OsIcon } from '@ocelot-social/ui'
import { mapGetters, mapMutations } from 'vuex'
import UniqueSlugForm from '~/components/utils/UniqueSlugForm'
import LocationSelect from '~/components/Select/LocationSelect'
@ -57,6 +57,7 @@ export default {
name: 'Settings',
components: {
OsButton,
OsIcon,
LocationSelect,
},
data() {

View File

@ -17,7 +17,7 @@
:label="$t('settings.email.labelNonce')"
/>
<os-button variant="primary" appearance="filled" type="submit" :disabled="!!errors">
<template #icon><base-icon name="check" /></template>
<template #icon><os-icon name="check" /></template>
{{ $t('actions.save') }}
</os-button>
</base-card>
@ -26,10 +26,10 @@
</template>
<script>
import { OsButton } from '@ocelot-social/ui'
import { OsButton, OsIcon } from '@ocelot-social/ui'
export default {
components: { OsButton },
components: { OsButton, OsIcon },
data() {
return {
formSchema: {

View File

@ -25,7 +25,7 @@
variant="primary"
appearance="filled"
>
<template #icon><base-icon name="check" /></template>
<template #icon><os-icon name="check" /></template>
{{ $t('actions.save') }}
</os-button>
</base-card>
@ -35,7 +35,7 @@
<script>
import { mapGetters } from 'vuex'
import { OsButton } from '@ocelot-social/ui'
import { OsButton, OsIcon } from '@ocelot-social/ui'
import { AddEmailAddressMutation } from '~/graphql/EmailAddress.js'
import { SweetalertIcon } from 'vue-sweetalert-icons'
import scrollToContent from '../scroll-to-content.js'
@ -44,6 +44,7 @@ export default {
mixins: [scrollToContent],
components: {
OsButton,
OsIcon,
SweetalertIcon,
},
data() {

View File

@ -0,0 +1,82 @@
/**
* Jest mock for @ocelot-social/ui/ocelot
*
* Same vue-demi patch mechanism as ui.js, but loads dist/ocelot.cjs.
*/
const path = require('path')
const Module = require('module')
// Load vue-demi from webapp's node_modules
// __dirname is test/__mocks__/@ocelot-social/ui, so go up 4 levels to webapp
const vueDemiPath = path.resolve(__dirname, '../../../../node_modules/vue-demi/lib/index.cjs')
const vueDemi = require(vueDemiPath)
// Verify vue-demi is correctly configured for Vue 2
if (!vueDemi.isVue2) {
throw new Error('vue-demi is not configured for Vue 2! isVue2=' + vueDemi.isVue2)
}
// Patch missing Composition API functions from Vue.default
// This is needed because Jest loads vue.runtime.common.js which exports under default
const Vue = require('vue')
const VueApi = Vue.default || Vue
// List of Composition API functions that vue-demi should export
const compositionApiFns = [
'defineComponent',
'computed',
'ref',
'reactive',
'watch',
'watchEffect',
'onMounted',
'onUnmounted',
'onBeforeMount',
'onBeforeUnmount',
'provide',
'inject',
'toRef',
'toRefs',
'unref',
'isRef',
'shallowRef',
'triggerRef',
'customRef',
'shallowReactive',
'shallowReadonly',
'readonly',
'toRaw',
'markRaw',
'effectScope',
'getCurrentScope',
'onScopeDispose',
'getCurrentInstance',
'h',
'nextTick',
]
// Patch any missing functions
for (const fn of compositionApiFns) {
if (!vueDemi[fn] && VueApi[fn]) {
vueDemi[fn] = VueApi[fn]
}
}
// Patch Module._load to return the correct vue-demi when the UI library loads it
const originalLoad = Module._load
Module._load = function (request, parent, isMain) {
if (request === 'vue-demi') {
return vueDemi
}
return originalLoad.apply(this, arguments)
}
// Load the UI library ocelot dist
const ocelotDistPath = path.resolve(
__dirname,
'../../../../node_modules/@ocelot-social/ui/dist/ocelot.cjs',
)
const ocelot = require(ocelotDistPath)
// Export everything from the ocelot dist
module.exports = ocelot