feat(package/ui): os-icon (#9234)
@ -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) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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 */
|
||||
|
||||
@ -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!')
|
||||
|
||||
@ -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>
|
||||
|
||||
225
packages/ui/src/components/OsIcon/OsIcon.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
143
packages/ui/src/components/OsIcon/OsIcon.stories.ts
Normal 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>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
100
packages/ui/src/components/OsIcon/OsIcon.visual.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
119
packages/ui/src/components/OsIcon/OsIcon.vue
Normal 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>
|
||||
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
10
packages/ui/src/components/OsIcon/icon.variants.ts
Normal 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]',
|
||||
}
|
||||
17
packages/ui/src/components/OsIcon/icons/index.ts
Normal 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
|
||||
3
packages/ui/src/components/OsIcon/icons/svgs/check.svg
Normal 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 |
3
packages/ui/src/components/OsIcon/icons/svgs/close.svg
Normal 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 |
3
packages/ui/src/components/OsIcon/icons/svgs/plus.svg
Normal 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 |
3
packages/ui/src/components/OsIcon/index.ts
Normal 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'
|
||||
@ -8,3 +8,12 @@
|
||||
*/
|
||||
|
||||
export { OsButton, buttonVariants, type ButtonVariants } from './OsButton'
|
||||
export {
|
||||
OsIcon,
|
||||
ICON_SIZES,
|
||||
IconCheck,
|
||||
IconClose,
|
||||
IconPlus,
|
||||
SYSTEM_ICONS,
|
||||
type SystemIconName,
|
||||
} from './OsIcon'
|
||||
|
||||
46
packages/ui/src/ocelot/icons/OcelotIcons.stories.ts
Normal 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>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
73
packages/ui/src/ocelot/icons/OcelotIcons.visual.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
After Width: | Height: | Size: 9.0 KiB |
62
packages/ui/src/ocelot/icons/index.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
24
packages/ui/src/ocelot/icons/index.ts
Normal 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]),
|
||||
)
|
||||
5
packages/ui/src/ocelot/icons/svgs/angle-down.svg
Executable 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 |
2
packages/ui/src/ocelot/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
// Ocelot migration icons — temporary, will be removed after Vue 3 migration
|
||||
export { ocelotIcons } from './icons'
|
||||
64
packages/ui/src/plugins/vite-svg-icon.ts
Normal 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}])
|
||||
`
|
||||
},
|
||||
}
|
||||
}
|
||||
@ -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
@ -0,0 +1,6 @@
|
||||
declare module '*.svg?icon' {
|
||||
import type { VNode } from 'vue-demi'
|
||||
|
||||
const icon: () => VNode
|
||||
export default icon
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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: '' },
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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' },
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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/,
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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() {
|
||||
|
||||
82
webapp/test/__mocks__/@ocelot-social/ui/ocelot.js
Normal 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
|
||||