refactor(package/ui): os-spinner (#9245)

This commit is contained in:
Ulf Gebhardt 2026-02-19 02:51:05 +01:00 committed by GitHub
parent c3a65a410e
commit daafde24b0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 976 additions and 249 deletions

View File

@ -81,10 +81,10 @@ Phase 0: ██████████ 100% (6/6 Aufgaben) ✅
Phase 1: ██████████ 100% (6/6 Aufgaben) ✅
Phase 2: ██████████ 100% (26/26 Aufgaben) ✅
Phase 3: ██████████ 100% (24/24 Aufgaben) ✅ - Webapp-Integration komplett
Phase 4: ██░░░░░░░░ 24% (4/17 Aufgaben) - OsButton ✅, OsIcon ✅, System-Icons ✅, BaseIcon→OsIcon Migration ✅
Phase 4: ████░░░░░░ 35% (6/17 Aufgaben) - OsButton ✅, OsIcon ✅, System-Icons ✅, BaseIcon→OsIcon Migration ✅, OsSpinner ✅, Spinner Webapp-Migration ✅
Phase 5: ░░░░░░░░░░ 0% (0/7 Aufgaben)
───────────────────────────────────────
Gesamt: ████████░░ 77% (66/86 Aufgaben)
Gesamt: ████████░░ 79% (68/86 Aufgaben)
```
### Katalogisierung (Details in KATALOG.md)
@ -133,6 +133,25 @@ System-Icons:
Ocelot-Icons (separates Entry-Point):
└─ 82 Icons (Feature-Icons + Kategorie-Icons aus Webapp migriert)
OsSpinner:
├─ size: ✅ xs, sm, md, lg, xl, 2xl (em-basiert)
├─ color: ✅ currentColor (erbt von Parent)
├─ a11y: ✅ role="status", aria-label="Loading" (customizable)
├─ decorative: ✅ aria-hidden="true" suppresses role/aria-label
├─ os-button: ✅ OsButton nutzt OsSpinner als Komponente (decorative)
├─ vue-compat: ✅ h() Render-Function mit isVue2
└─ webapp: ✅ 4 Spinner migriert (DsSpinner + LoadingSpinner → OsSpinner)
DsSpinner/LoadingSpinner → OsSpinner Webapp-Migration: ✅
├─ ImageUploader.vue: LoadingSpinner → OsSpinner (size="lg")
├─ pages/profile: ds-spinner → os-spinner (size="lg")
├─ pages/groups: ds-spinner → os-spinner (size="lg")
├─ pages/admin: ds-spinner → os-spinner (size="xl") + ApolloQuery→apollo Option
├─ LoadingSpinner Komponente gelöscht
├─ ds-space centered → div+padding (Bugfix in 3 Seiten)
├─ Admin: ApolloQuery→$apollo.loading (Spinner war wg. SSR-Prefetch unsichtbar)
└─ infinite-loading: OsSpinner im spinner-Slot (index, profile, groups)
BaseIcon → OsIcon Webapp-Migration: ✅
├─ 131 <base-icon> in 70+ Dateien → <os-icon :icon="...">
├─ 82 SVGs in ocelot/icons/svgs/ (inkl. 17 Kategorie-Icons)
@ -147,11 +166,35 @@ BaseIcon → OsIcon Webapp-Migration: ✅
## Aktueller Stand
**Letzte Aktualisierung:** 2026-02-15 (Session 22)
**Letzte Aktualisierung:** 2026-02-18 (Session 24)
**Aktuelle Phase:** Phase 4 - OsIcon ✅, BaseIcon → OsIcon Webapp-Migration ✅
**Aktuelle Phase:** Phase 4 - OsIcon ✅, BaseIcon → OsIcon Migration ✅, OsSpinner ✅, Spinner Webapp-Migration ✅
**Zuletzt abgeschlossen (Session 22 - BaseIcon → OsIcon Webapp-Migration):**
**Zuletzt abgeschlossen (Session 24 - OsSpinner Webapp-Migration + Refactoring):**
- [x] OsButton refactored: nutzt `h(OsSpinner, { 'aria-hidden': 'true' })` statt Inline-SVG
- [x] OsSpinner: Decorative-Modus (`aria-hidden="true"` unterdrückt role/aria-label)
- [x] `ButtonSize` Type exportiert (sm/md/lg/xl), `types.d.ts` Kommentar aktualisiert
- [x] `createSpinnerSvg.ts` wieder in OsSpinner.vue inlined (nur noch 1 Nutzer)
- [x] Webapp-Migration: 4 Spinner ersetzt (ImageUploader, profile, groups, admin)
- [x] LoadingSpinner Komponente gelöscht (ersetzt durch OsSpinner)
- [x] `<ds-space centered>``<div style="...">` Bugfix in 3 Seiten
- [x] Admin-Seite: `<ApolloQuery>``apollo`-Option + `$apollo.loading` (Spinner war wg. SSR-Prefetch unsichtbar)
- [x] `filterStatistics()` mutiert nicht mehr Originalobjekt (Destructuring statt `delete`)
- [x] `infinite-loading` Spinner-Slot: OsSpinner in allen 3 Nutzungen (index, profile, groups)
- [x] Einheitliches Spinner-Design: vue-infinite-loading Default-Spinner → OsSpinner
**Zuvor abgeschlossen (Session 23 - OsSpinner Komponente):**
- [x] OsSpinner Komponente implementiert (size prop, currentColor, role="status", aria-label)
- [x] Vue 2/3 kompatibel via `h()` Render-Function mit `isVue2`
- [x] Storybook Stories: Playground, AllSizes, InheritColor, InlineWithText
- [x] Unit Tests: 23 Tests (rendering, size, accessibility, decorative mode, css, keyboard)
- [x] Visual Tests: 4 Tests (all-sizes, inherit-color, inline-text, keyboard a11y)
- [x] Accessibility: `role="status"`, `aria-label="Loading"` (customizable), axe-core checks
- [x] 100% Test-Coverage (Statements, Branches, Functions, Lines)
- [x] Completeness Check bestanden
- [x] OsButton Visual Tests: 19/19 bestanden (kein Regression durch Refactoring)
**Zuvor abgeschlossen (Session 22 - BaseIcon → OsIcon Webapp-Migration):**
- [x] 131 `<base-icon>` Nutzungen in 70+ Dateien → `<os-icon :icon="icons.xxx">` migriert
- [x] 82 Ocelot-Icons in `packages/ui/src/ocelot/icons/svgs/` (von 1 auf 82)
- [x] 17 Kategorie-Icons aus Webapp kopiert (networking, energy, psyche, movement, finance, child, mobility, shopping-cart, peace, politics, nature, science, health, media, spirituality, culture, miscellaneous)
@ -219,7 +262,7 @@ BaseIcon → OsIcon Webapp-Migration: ✅
- [x] Session 11: Wasserfarben-Farbschema, Stories konsolidiert, Keyboard A11y
**Nächste Schritte:**
- [ ] OsSpinner Komponente (vereint DsSpinner + LoadingSpinner)
- [x] OsSpinner Webapp-Migration (DsSpinner + LoadingSpinner → OsSpinner) ✅
- [ ] OsCard Komponente (vereint DsCard + BaseCard)
- [ ] Weitere Tier 1 Komponenten
- [ ] Browser-Fehler untersuchen: `TypeError: Cannot read properties of undefined (reading 'heartO')` (ocelotIcons undefined im Browser trotz korrekter Webpack-Aliase)
@ -473,7 +516,8 @@ Jeder migrierte Button muss manuell geprüft werden: Normal, Hover, Focus, Activ
**Tier 1: Kern-Komponenten**
- [x] OsIcon (vereint DsIcon + BaseIcon) ✅ System-Icons + vite-svg-icon Plugin
- [ ] OsSpinner (vereint DsSpinner + LoadingSpinner)
- [x] OsSpinner (vereint DsSpinner + LoadingSpinner) ✅ OsButton nutzt OsSpinner als Komponente
- [x] OsSpinner Webapp-Migration ✅ 4 Spinner migriert, LoadingSpinner gelöscht, Admin ApolloQuery→$apollo.loading
- [x] OsButton (vereint DsButton + BaseButton) ✅ Entwickelt in Phase 2
- [ ] OsCard (vereint DsCard + BaseCard)
@ -1577,6 +1621,14 @@ Bei der Migration werden:
| 2026-02-15 | **8 Snapshots gelöscht** | Stale Snapshot-Dateien entfernt nach BaseIcon → OsIcon Migration |
| 2026-02-15 | **CSS Migration** | `.base-icon``.os-icon` in main.scss und Category/index.vue |
| 2026-02-15 | **Jest Mock ocelot** | `test/__mocks__/@ocelot-social/ui/ocelot.js` für ocelotIcons in Jest-Umgebung |
| 2026-02-18 | **OsSpinner Komponente** | Neue Komponente: size (xs-2xl, em-basiert), currentColor, role="status", aria-label; Vue 2/3 via h() + isVue2 |
| 2026-02-18 | **OsSpinner Decorative** | `aria-hidden="true"` unterdrückt role/aria-label; OsButton nutzt OsSpinner als Komponente (decorative) |
| 2026-02-18 | **ButtonSize Type** | `ButtonSize` (sm/md/lg/xl) exportiert aus button.variants.ts; types.d.ts Kommentar: Size ist Vokabular, nicht Pflicht |
| 2026-02-18 | **OsSpinner Webapp-Migration** | 4 Stellen migriert: ImageUploader, profile, groups, admin; LoadingSpinner gelöscht |
| 2026-02-18 | **ds-space centered Bugfix** | `<ds-space centered>``<div style="text-align:center;padding:48px 0">` in 3 Seiten (Styleguide-Bug) |
| 2026-02-18 | **Admin Spinner Fix** | `<ApolloQuery>``apollo`-Option + `$apollo.loading`; SSR-Prefetch verhinderte Loading-State im Client |
| 2026-02-18 | **filterStatistics Fix** | `delete data.__typename` → Destructuring `{ __typename, ...rest }` (keine Mutation des Originalobjekts) |
| 2026-02-18 | **infinite-loading Spinner-Slot** | OsSpinner im `spinner`-Slot von vue-infinite-loading in 3 Seiten (index, profile, groups); einheitliches Spinner-Design |
---

View File

@ -1,11 +1,12 @@
<script lang="ts">
import { computed, defineComponent, getCurrentInstance, h, isVue2 } from 'vue-demi'
import OsSpinner from '#src/components/OsSpinner/OsSpinner.vue'
import { cn } from '#src/utils'
import { buttonVariants } from './button.variants'
import type { ButtonVariants } from './button.variants'
import type { ButtonSize, ButtonVariants } from './button.variants'
import type { Component, PropType } from 'vue-demi'
/**
@ -24,9 +25,7 @@
* @slot suffix - Optional trailing content (rendered right of text). Icons, badges, chevrons etc.
*/
type Size = NonNullable<ButtonVariants['size']>
const CIRCLE_WIDTHS: Record<Size, string> = {
const CIRCLE_WIDTHS: Record<ButtonSize, string> = {
sm: 'w-[26px]',
md: 'w-[36px]',
lg: 'w-12',
@ -38,42 +37,14 @@
const ICON_CLASS = `os-button__icon ${SLOT_BASE}`
const SUFFIX_CLASS = `os-button__suffix ${SLOT_BASE}`
const SPINNER_PX: Record<Size, number> = { sm: 24, md: 32, lg: 40, xl: 46 }
const SVG_ATTRS = {
viewBox: '0 0 50 50',
xmlns: 'http://www.w3.org/2000/svg',
'aria-hidden': 'true',
}
const CIRCLE_ATTRS = {
cx: '25',
cy: '25',
r: '20',
fill: 'none',
stroke: 'currentColor',
'stroke-width': '4',
'stroke-linecap': 'round',
}
const CIRCLE_STYLE =
'transform-origin:25px 25px;animation:os-spinner-rotate 16s linear infinite,os-spinner-dash 1.5s ease-in-out infinite'
function vueAttrs(attrs: Record<string, string>, style?: string) {
/* v8 ignore start -- Vue 2 branch tested in webapp Jest tests */
return isVue2 ? { attrs, ...(style && { style }) } : { ...attrs, ...(style && { style }) }
/* v8 ignore stop */
}
const SPINNER_PX: Record<ButtonSize, number> = { sm: 24, md: 32, lg: 40, xl: 46 }
function createSpinner(px: number, center: string) {
const svg = h('svg', vueAttrs(SVG_ATTRS, 'width:100%;height:100%;overflow:hidden'), [
h('circle', vueAttrs(CIRCLE_ATTRS, CIRCLE_STYLE)),
])
return h(
'span',
{ class: `os-button__spinner absolute ${center}`, style: `width:${px}px;height:${px}px` },
[svg],
)
return h(OsSpinner, {
class: `os-button__spinner absolute ${center}`,
style: `width:${px}px;height:${px}px`,
'aria-hidden': 'true',
})
}
export default defineComponent({
@ -148,7 +119,7 @@
return typeof children !== 'string' || children.trim().length > 0
}) ?? false
const size = props.size as Size
const size = props.size as ButtonSize
const spinnerPx = SPINNER_PX[size] // eslint-disable-line security/detect-object-injection
const isSmall = props.circle || size === 'sm'
const isLoading = props.loading

View File

@ -269,3 +269,6 @@ export const buttonVariants = cva(
)
export type ButtonVariants = VariantProps<typeof buttonVariants>
/** Button-specific size subset: sm | md | lg | xl */
export type ButtonSize = NonNullable<ButtonVariants['size']>

View File

@ -1,2 +1,2 @@
export { default as OsButton } from './OsButton.vue'
export { buttonVariants, type ButtonVariants } from './button.variants'
export { buttonVariants, type ButtonSize, type ButtonVariants } from './button.variants'

View File

@ -0,0 +1,149 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import OsSpinner from './OsSpinner.vue'
import { SPINNER_SIZES } from './spinner.variants'
import type { Size } from '#src/types'
describe('osSpinner', () => {
describe('rendering', () => {
it('renders a spinner element', () => {
const wrapper = mount(OsSpinner)
expect(wrapper.find('.os-spinner').exists()).toBe(true)
})
it('renders an SVG circle', () => {
const wrapper = mount(OsSpinner)
expect(wrapper.find('svg').exists()).toBe(true)
expect(wrapper.find('circle').exists()).toBe(true)
})
it('renders as span element', () => {
const wrapper = mount(OsSpinner)
expect((wrapper.element as HTMLElement).tagName).toBe('SPAN')
})
it('svg has correct viewBox', () => {
const wrapper = mount(OsSpinner)
expect(wrapper.find('svg').attributes('viewBox')).toBe('0 0 50 50')
})
it('circle uses currentColor for stroke', () => {
const wrapper = mount(OsSpinner)
expect(wrapper.find('circle').attributes('stroke')).toBe('currentColor')
})
it('circle has animation style', () => {
const wrapper = mount(OsSpinner)
const style = wrapper.find('circle').attributes('style') ?? ''
expect(style).toContain('os-spinner-rotate')
expect(style).toContain('os-spinner-dash')
})
})
describe('size prop', () => {
const sizes = Object.entries(SPINNER_SIZES)
it.each(sizes)('applies %s size classes', (size, expectedClasses) => {
const wrapper = mount(OsSpinner, {
props: { size: size as Size },
})
const classes = wrapper.classes()
for (const cls of expectedClasses.split(' ')) {
expect(classes).toContain(cls)
}
})
it('defaults to md size', () => {
const wrapper = mount(OsSpinner)
expect(wrapper.classes()).toContain('h-[1.5em]')
expect(wrapper.classes()).toContain('w-[1.5em]')
})
})
describe('accessibility', () => {
it('has role="status"', () => {
const wrapper = mount(OsSpinner)
expect(wrapper.attributes('role')).toBe('status')
})
it('has default aria-label "Loading"', () => {
const wrapper = mount(OsSpinner)
expect(wrapper.attributes('aria-label')).toBe('Loading')
})
it('allows custom aria-label', () => {
const wrapper = mount(OsSpinner, {
attrs: { 'aria-label': 'Saving changes' },
})
expect(wrapper.attributes('aria-label')).toBe('Saving changes')
})
it('is decorative when aria-hidden="true"', () => {
const wrapper = mount(OsSpinner, {
attrs: { 'aria-hidden': 'true' },
})
expect(wrapper.attributes('aria-hidden')).toBe('true')
expect(wrapper.attributes('role')).toBeUndefined()
expect(wrapper.attributes('aria-label')).toBeUndefined()
})
it('is semantic by default (no aria-hidden)', () => {
const wrapper = mount(OsSpinner)
expect(wrapper.attributes('aria-hidden')).toBeUndefined()
expect(wrapper.attributes('role')).toBe('status')
})
})
describe('css', () => {
it('has inline-flex display', () => {
const wrapper = mount(OsSpinner)
expect(wrapper.classes()).toContain('inline-flex')
})
it('has shrink-0 class', () => {
const wrapper = mount(OsSpinner)
expect(wrapper.classes()).toContain('shrink-0')
})
it('merges custom classes', () => {
const wrapper = mount(OsSpinner, {
attrs: { class: 'text-red-500' },
})
expect(wrapper.classes()).toContain('text-red-500')
expect(wrapper.classes()).toContain('os-spinner')
})
})
describe('keyboard accessibility', () => {
it('is not focusable (non-interactive element)', () => {
const wrapper = mount(OsSpinner)
expect(wrapper.attributes('tabindex')).toBeUndefined()
})
it('has no interactive role', () => {
const wrapper = mount(OsSpinner)
expect(wrapper.attributes('role')).not.toBe('button')
expect(wrapper.attributes('role')).not.toBe('link')
})
})
})

View File

@ -0,0 +1,113 @@
import { computed } from 'vue'
import OsSpinner from './OsSpinner.vue'
import type { Meta, StoryObj } from '@storybook/vue3-vite'
const meta: Meta<typeof OsSpinner> = {
title: 'Components/OsSpinner',
component: OsSpinner,
tags: ['autodocs'],
}
export default meta
type Story = StoryObj<typeof OsSpinner>
interface PlaygroundArgs {
size: string
ariaLabel: string
}
export const Playground: StoryObj<PlaygroundArgs> = {
argTypes: {
size: {
control: 'select',
options: ['xs', 'sm', 'md', 'lg', 'xl', '2xl'],
},
ariaLabel: {
control: 'text',
},
},
args: {
size: 'md',
ariaLabel: 'Loading',
},
render: (args) => ({
components: { OsSpinner },
setup() {
const spinnerProps = computed(() => ({
size: args.size,
}))
const ariaLabel = computed(() => args.ariaLabel || undefined)
return { spinnerProps, ariaLabel }
},
template: `<OsSpinner v-bind="spinnerProps" :aria-label="ariaLabel" />`,
}),
}
export const AllSizes: Story = {
render: () => ({
components: { OsSpinner },
template: `
<div data-testid="all-sizes" class="flex items-end gap-6">
<div class="flex flex-col items-center gap-2">
<OsSpinner size="xs" />
<span class="text-xs text-gray-500">xs</span>
</div>
<div class="flex flex-col items-center gap-2">
<OsSpinner size="sm" />
<span class="text-xs text-gray-500">sm</span>
</div>
<div class="flex flex-col items-center gap-2">
<OsSpinner size="md" />
<span class="text-xs text-gray-500">md</span>
</div>
<div class="flex flex-col items-center gap-2">
<OsSpinner size="lg" />
<span class="text-xs text-gray-500">lg</span>
</div>
<div class="flex flex-col items-center gap-2">
<OsSpinner size="xl" />
<span class="text-xs text-gray-500">xl</span>
</div>
<div class="flex flex-col items-center gap-2">
<OsSpinner size="2xl" />
<span class="text-xs text-gray-500">2xl</span>
</div>
</div>
`,
}),
}
export const InheritColor: Story = {
render: () => ({
components: { OsSpinner },
template: `
<div data-testid="inherit-color" class="flex items-center gap-6 text-lg">
<span class="text-red-500"><OsSpinner size="lg" aria-label="Error loading" /></span>
<span class="text-green-500"><OsSpinner size="lg" aria-label="Success loading" /></span>
<span class="text-blue-500"><OsSpinner size="lg" aria-label="Info loading" /></span>
<span class="text-gray-400"><OsSpinner size="lg" aria-label="Default loading" /></span>
</div>
`,
}),
}
export const InlineWithText: Story = {
render: () => ({
components: { OsSpinner },
template: `
<div data-testid="inline-text" class="flex flex-col gap-4">
<p class="text-sm flex items-center gap-2">
<OsSpinner size="sm" /> Loading results...
</p>
<p class="text-base flex items-center gap-2">
<OsSpinner size="md" /> Processing your request...
</p>
<p class="text-lg flex items-center gap-2">
<OsSpinner size="lg" /> Please wait...
</p>
</div>
`,
}),
}

View File

@ -0,0 +1,101 @@
import { AxeBuilder } from '@axe-core/playwright'
import { expect, test } from '@playwright/test'
import type { Page } from '@playwright/test'
/**
* Visual regression tests for OsSpinner 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.
*
* Note: Spinner animations are paused via Playwright's CSS override
* to ensure deterministic screenshots.
*/
const STORY_URL = '/iframe.html?id=components-osspinner'
const STORY_ROOT = '#storybook-root'
/**
* Wait for fonts and pause animations for deterministic screenshots
*/
async function waitForReady(page: Page) {
await page.evaluate(async () => document.fonts.ready)
// Pause all animations for deterministic screenshots
await page.addStyleTag({
content: '*, *::before, *::after { animation-play-state: paused !important; }',
})
}
/**
* 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('OsSpinner keyboard accessibility', () => {
test('spinner is not focusable and has status role', async ({ page }) => {
await page.goto(`${STORY_URL}--all-sizes&viewMode=story`)
const root = page.locator(STORY_ROOT)
await root.waitFor()
const spinners = root.locator('.os-spinner')
const count = await spinners.count()
expect(count).toBeGreaterThan(0)
for (let i = 0; i < count; i++) {
const spinner = spinners.nth(i)
await expect(spinner).toHaveAttribute('role', 'status')
await expect(spinner).not.toHaveAttribute('tabindex')
}
// Verify no spinner receives focus via Tab navigation
await page.keyboard.press('Tab')
for (let i = 0; i < count; i++) {
await expect(spinners.nth(i)).not.toBeFocused()
}
})
})
test.describe('OsSpinner visual regression', () => {
test('all sizes', async ({ page }) => {
await page.goto(`${STORY_URL}--all-sizes&viewMode=story`)
const root = page.locator(STORY_ROOT)
await root.waitFor()
await waitForReady(page)
await expect(root.locator('[data-testid="all-sizes"]')).toHaveScreenshot('all-sizes.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 waitForReady(page)
await expect(root.locator('[data-testid="inherit-color"]')).toHaveScreenshot(
'inherit-color.png',
)
await checkA11y(page)
})
test('inline with text', async ({ page }) => {
await page.goto(`${STORY_URL}--inline-with-text&viewMode=story`)
const root = page.locator(STORY_ROOT)
await root.waitFor()
await waitForReady(page)
await expect(root.locator('[data-testid="inline-text"]')).toHaveScreenshot('inline-text.png')
await checkA11y(page)
})
})

View File

@ -0,0 +1,120 @@
<script lang="ts">
import { defineComponent, getCurrentInstance, h, isVue2 } from 'vue-demi'
import { cn } from '#src/utils'
import { SPINNER_SIZES } from './spinner.variants'
import type { Size } from '#src/types'
import type { ClassValue } from 'clsx'
import type { PropType } from 'vue-demi'
const SVG_ATTRS = {
viewBox: '0 0 50 50',
xmlns: 'http://www.w3.org/2000/svg',
}
const CIRCLE_ATTRS = {
cx: '25',
cy: '25',
r: '20',
fill: 'none',
stroke: 'currentColor',
'stroke-width': '4',
'stroke-linecap': 'round',
}
const CIRCLE_STYLE =
'transform-origin:25px 25px;animation:os-spinner-rotate 16s linear infinite,os-spinner-dash 1.5s ease-in-out infinite'
function vueAttrs(attrs: Record<string, string>, style: string): Record<string, unknown> {
/* v8 ignore start -- Vue 2 branch tested in webapp Jest tests */
return isVue2 ? { attrs, style } : { ...attrs, style }
/* v8 ignore stop */
}
function createSvg() {
const circle = h('circle', vueAttrs(CIRCLE_ATTRS, CIRCLE_STYLE))
return h('svg', vueAttrs(SVG_ATTRS, 'width:100%;height:100%;overflow:hidden'), [circle])
}
/**
* Animated loading spinner with configurable size.
* Inherits color from parent via `currentColor`.
*
* Semantic by default (`role="status"`, `aria-label="Loading"`).
* Pass `aria-hidden="true"` to make it decorative (suppresses role/aria-label).
*
* @prop size - Spinner size (xs/sm/md/lg/xl/2xl)
*/
export default defineComponent({
name: 'OsSpinner',
inheritAttrs: false,
props: {
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 () => {
const sizeClass = SPINNER_SIZES[props.size]
const svg = createSvg()
/* 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 isDecorative =
String(parentAttrs['aria-hidden']) === 'true' || String(attrs['aria-hidden']) === 'true'
const a11yAttrs = isDecorative
? { 'aria-hidden': 'true' }
: {
role: 'status',
'aria-label': parentAttrs['aria-label'] || attrs['aria-label'] || 'Loading',
}
return h(
'span',
{
class: cn('os-spinner inline-flex shrink-0', sizeClass, parentClass, parentDynClass),
attrs: { ...parentAttrs, ...attrs, ...a11yAttrs },
},
[svg],
)
}
/* v8 ignore stop */
const {
class: attrClass,
'aria-label': ariaLabel,
'aria-hidden': ariaHidden,
...restAttrs
} = attrs as Record<string, unknown>
const isDecorative = String(ariaHidden) === 'true'
const a11yAttrs = isDecorative
? { 'aria-hidden': 'true' as const }
: { role: 'status' as const, 'aria-label': (ariaLabel as string) || 'Loading' }
return h(
'span',
{
class: cn('os-spinner inline-flex shrink-0', sizeClass, attrClass as ClassValue),
...restAttrs,
...a11yAttrs,
},
[svg],
)
}
},
})
</script>

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

View File

@ -0,0 +1,2 @@
export { default as OsSpinner } from './OsSpinner.vue'
export { SPINNER_SIZES } from './spinner.variants'

View File

@ -0,0 +1,13 @@
import type { Size } from '#src/types'
/**
* Spinner size classes (em-based, scales with parent font-size)
*/
export const SPINNER_SIZES: Record<Size, string> = {
xs: 'h-[0.75em] w-[0.75em]',
sm: 'h-[1em] w-[1em]',
md: 'h-[1.5em] w-[1.5em]',
lg: 'h-[2em] w-[2em]',
xl: 'h-[2.5em] w-[2.5em]',
'2xl': 'h-[3em] w-[3em]',
}

View File

@ -7,7 +7,7 @@
* - Registered globally when using the plugin: app.use(OcelotUI)
*/
export { OsButton, buttonVariants, type ButtonVariants } from './OsButton'
export { OsButton, buttonVariants, type ButtonSize, type ButtonVariants } from './OsButton'
export {
OsIcon,
ICON_SIZES,
@ -17,3 +17,4 @@ export {
SYSTEM_ICONS,
type SystemIconName,
} from './OsIcon'
export { OsSpinner, SPINNER_SIZES } from './OsSpinner'

View File

@ -1,13 +1,15 @@
/**
* Component prop types based on Tailwind CSS scales
* Shared vocabulary types for component props.
*
* These types ensure consistency across all components.
* When a component supports a prop, it must support all values of that scale.
* These types define a common naming convention across components.
* Components pick the subset that makes sense for them:
* - OsIcon/OsSpinner: all 6 sizes (inline elements, em-based)
* - OsButton: smxl (interactive element, pixel-based touch targets)
*/
/**
* Size scale for components (buttons, inputs, avatars, etc.)
* Maps to Tailwind's text/spacing scale
* Size vocabulary shared across components.
* Each component supports the subset that makes sense for its context.
*/
export type Size = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl'

View File

@ -13,9 +13,8 @@ RUN apk --no-cache add git python3 make g++
RUN mkdir -p /app
WORKDIR /app
COPY packages/ui .
RUN --mount=type=cache,target=/yarn-cache,sharing=locked \
yarn install --production=false --frozen-lockfile --non-interactive --cache-folder /yarn-cache
RUN yarn run build
RUN npm ci
RUN npm run build
FROM node:25.6.1-alpine AS base
LABEL org.label-schema.name="ocelot.social:webapp"

View File

@ -23,9 +23,8 @@ RUN apk --no-cache add git python3 make g++
RUN mkdir -p /app
WORKDIR /app
COPY packages/ui .
RUN --mount=type=cache,target=/yarn-cache,sharing=locked \
yarn install --production=false --frozen-lockfile --non-interactive --cache-folder /yarn-cache
RUN yarn run build
RUN npm ci
RUN npm run build
FROM node:25.6.1-alpine AS build
ENV NODE_ENV="production"

View File

@ -7,7 +7,7 @@
:use-custom-slot="true"
@vdropzone-file-added="fileAdded"
>
<loading-spinner v-if="isLoadingImage" />
<os-spinner v-if="isLoadingImage" size="lg" />
<os-icon v-else-if="!hasImage" :icon="icons.image" />
<div v-if="!hasImage" class="supported-formats">
{{ $t('contribution.teaserImage.supportedFormats') }}
@ -58,20 +58,19 @@
</template>
<script>
import { OsButton, OsIcon } from '@ocelot-social/ui'
import { OsButton, OsIcon, OsSpinner } from '@ocelot-social/ui'
import { iconRegistry } from '~/utils/iconRegistry'
import Cropper from 'cropperjs'
import VueDropzone from 'nuxt-dropzone'
import LoadingSpinner from '~/components/_new/generic/LoadingSpinner/LoadingSpinner'
import 'cropperjs/dist/cropper.css'
const minAspectRatio = 0.3
export default {
components: {
LoadingSpinner,
OsButton,
OsIcon,
OsSpinner,
VueDropzone,
},
props: {

View File

@ -1,11 +0,0 @@
import { storiesOf } from '@storybook/vue'
import helpers from '~/storybook/helpers'
import LoadingSpinner from './LoadingSpinner.vue'
storiesOf('Generic/LoadingSpinner', module)
.addDecorator(helpers.layout)
.add('default', () => ({
components: { LoadingSpinner },
template: '<loading-spinner />',
}))

View File

@ -1,48 +0,0 @@
<template>
<svg viewBox="0 0 50 50" class="loading-spinner">
<circle cx="25" cy="25" r="20" class="circle" />
</svg>
</template>
<script>
export default {
name: 'LoadingSpinner',
}
</script>
<style lang="scss">
.loading-spinner {
height: $size-button-base;
overflow: hidden;
stroke: currentColor;
animation: rotate 16s linear infinite;
> .circle {
fill: none;
stroke-width: 5;
stroke-linecap: round;
animation: dash 1.5s ease-in-out infinite;
}
}
@keyframes dash {
0% {
stroke-dasharray: 1, 150;
stroke-dashoffset: 0;
}
50% {
stroke-dasharray: 90, 150;
stroke-dashoffset: -35;
}
100% {
stroke-dasharray: 90, 150;
stroke-dashoffset: -124;
}
}
@keyframes rotate {
100% {
transform: rotate(2160deg);
}
}
</style>

View File

@ -1,69 +1,70 @@
<template>
<base-card>
<ApolloQuery :query="Statistics">
<template v-slot="{ result: { loading, error, data } }">
<template v-if="loading">
<ds-space centered>
<ds-spinner size="large"></ds-spinner>
</ds-space>
</template>
<template v-else-if="error">
<ds-space centered>
<ds-space>
<img :src="errorIconPath" width="40" />
<template v-if="$apollo.loading">
<div style="text-align: center; padding: 48px 0">
<os-spinner size="xl" />
</div>
</template>
<template v-else-if="statistics">
<ds-space margin="large">
<ds-flex>
<ds-flex-item
v-for="(value, name) in filterStatistics(statistics)"
:key="name"
:width="{ base: '100%', sm: '50%', md: '33%' }"
>
<ds-space margin="small">
<ds-number :count="0" :label="$t('admin.dashboard.' + name)" size="x-large" uppercase>
<client-only slot="count">
<hc-count-to :end-val="value" />
</client-only>
</ds-number>
</ds-space>
<ds-text>
{{ $t('site.error-occurred') }}
</ds-text>
</ds-space>
</template>
<template v-else-if="data">
<ds-space margin="large">
<ds-flex>
<ds-flex-item
v-for="(value, name, index) in filterStatistics(data.statistics)"
:key="index"
:width="{ base: '100%', sm: '50%', md: '33%' }"
>
<ds-space margin="small">
<ds-number
:count="0"
:label="$t('admin.dashboard.' + name)"
size="x-large"
uppercase
>
<client-only slot="count">
<hc-count-to :end-val="value" />
</client-only>
</ds-number>
</ds-space>
</ds-flex-item>
</ds-flex>
</ds-space>
</template>
</template>
</ApolloQuery>
</ds-flex-item>
</ds-flex>
</ds-space>
</template>
<template v-else>
<ds-space centered>
<ds-space>
<img :src="errorIconPath" width="40" />
</ds-space>
<ds-text>
{{ $t('site.error-occurred') }}
</ds-text>
</ds-space>
</template>
</base-card>
</template>
<script>
import { OsSpinner } from '@ocelot-social/ui'
import HcCountTo from '~/components/CountTo.vue'
import { Statistics } from '~/graphql/admin/Statistics'
export default {
components: {
OsSpinner,
HcCountTo,
},
data() {
return {
errorIconPath: '/img/svg/emoji/cry.svg',
Statistics,
statistics: null,
}
},
apollo: {
statistics: {
query: Statistics,
update(data) {
return data.statistics
},
},
},
methods: {
filterStatistics(data) {
delete data.__typename
return data
const { __typename, ...rest } = data
return rest
},
},
}

View File

@ -1071,13 +1071,31 @@ exports[`GroupProfileSlug given a puplic group "yoga-practice" given a close
<div
class="infinite-status-prompt"
data-v-644ea9c9=""
style="color: rgb(102, 102, 102); font-size: 14px; padding: 10px 0px; display: none;"
style="display: none;"
>
<i
class="loading-default"
data-v-46b20d22=""
<span
aria-label="Loading"
class="os-spinner inline-flex shrink-0 h-[2em] w-[2em]"
data-v-644ea9c9=""
/>
role="status"
>
<svg
style="width: 100%; height: 100%; overflow: hidden;"
viewBox="0 0 50 50"
xmlns="http://www.w3.org/2000/svg"
>
<circle
cx="25"
cy="25"
fill="none"
r="20"
stroke="currentColor"
stroke-linecap="round"
stroke-width="4"
style="transform-origin: 25px 25px; animation: os-spinner-rotate 16s linear infinite,os-spinner-dash 1.5s ease-in-out infinite;"
/>
</svg>
</span>
</div>
<div
@ -1582,13 +1600,31 @@ exports[`GroupProfileSlug given a puplic group "yoga-practice" given a close
<div
class="infinite-status-prompt"
data-v-644ea9c9=""
style="color: rgb(102, 102, 102); font-size: 14px; padding: 10px 0px; display: none;"
style="display: none;"
>
<i
class="loading-default"
data-v-46b20d22=""
<span
aria-label="Loading"
class="os-spinner inline-flex shrink-0 h-[2em] w-[2em]"
data-v-644ea9c9=""
/>
role="status"
>
<svg
style="width: 100%; height: 100%; overflow: hidden;"
viewBox="0 0 50 50"
xmlns="http://www.w3.org/2000/svg"
>
<circle
cx="25"
cy="25"
fill="none"
r="20"
stroke="currentColor"
stroke-linecap="round"
stroke-width="4"
style="transform-origin: 25px 25px; animation: os-spinner-rotate 16s linear infinite,os-spinner-dash 1.5s ease-in-out infinite;"
/>
</svg>
</span>
</div>
<div
@ -2113,13 +2149,31 @@ exports[`GroupProfileSlug given a puplic group "yoga-practice" given a close
<div
class="infinite-status-prompt"
data-v-644ea9c9=""
style="color: rgb(102, 102, 102); font-size: 14px; padding: 10px 0px; display: none;"
style="display: none;"
>
<i
class="loading-default"
data-v-46b20d22=""
<span
aria-label="Loading"
class="os-spinner inline-flex shrink-0 h-[2em] w-[2em]"
data-v-644ea9c9=""
/>
role="status"
>
<svg
style="width: 100%; height: 100%; overflow: hidden;"
viewBox="0 0 50 50"
xmlns="http://www.w3.org/2000/svg"
>
<circle
cx="25"
cy="25"
fill="none"
r="20"
stroke="currentColor"
stroke-linecap="round"
stroke-width="4"
style="transform-origin: 25px 25px; animation: os-spinner-rotate 16s linear infinite,os-spinner-dash 1.5s ease-in-out infinite;"
/>
</svg>
</span>
</div>
<div
@ -3147,13 +3201,31 @@ exports[`GroupProfileSlug given a puplic group "yoga-practice" given a close
<div
class="infinite-status-prompt"
data-v-644ea9c9=""
style="color: rgb(102, 102, 102); font-size: 14px; padding: 10px 0px; display: none;"
style="display: none;"
>
<i
class="loading-default"
data-v-46b20d22=""
<span
aria-label="Loading"
class="os-spinner inline-flex shrink-0 h-[2em] w-[2em]"
data-v-644ea9c9=""
/>
role="status"
>
<svg
style="width: 100%; height: 100%; overflow: hidden;"
viewBox="0 0 50 50"
xmlns="http://www.w3.org/2000/svg"
>
<circle
cx="25"
cy="25"
fill="none"
r="20"
stroke="currentColor"
stroke-linecap="round"
stroke-width="4"
style="transform-origin: 25px 25px; animation: os-spinner-rotate 16s linear infinite,os-spinner-dash 1.5s ease-in-out infinite;"
/>
</svg>
</span>
</div>
<div
@ -4200,13 +4272,31 @@ exports[`GroupProfileSlug given a puplic group "yoga-practice" given a curre
<div
class="infinite-status-prompt"
data-v-644ea9c9=""
style="color: rgb(102, 102, 102); font-size: 14px; padding: 10px 0px; display: none;"
style="display: none;"
>
<i
class="loading-default"
data-v-46b20d22=""
<span
aria-label="Loading"
class="os-spinner inline-flex shrink-0 h-[2em] w-[2em]"
data-v-644ea9c9=""
/>
role="status"
>
<svg
style="width: 100%; height: 100%; overflow: hidden;"
viewBox="0 0 50 50"
xmlns="http://www.w3.org/2000/svg"
>
<circle
cx="25"
cy="25"
fill="none"
r="20"
stroke="currentColor"
stroke-linecap="round"
stroke-width="4"
style="transform-origin: 25px 25px; animation: os-spinner-rotate 16s linear infinite,os-spinner-dash 1.5s ease-in-out infinite;"
/>
</svg>
</span>
</div>
<div
@ -5014,13 +5104,31 @@ exports[`GroupProfileSlug given a puplic group "yoga-practice" given a curre
<div
class="infinite-status-prompt"
data-v-644ea9c9=""
style="color: rgb(102, 102, 102); font-size: 14px; padding: 10px 0px; display: none;"
style="display: none;"
>
<i
class="loading-default"
data-v-46b20d22=""
<span
aria-label="Loading"
class="os-spinner inline-flex shrink-0 h-[2em] w-[2em]"
data-v-644ea9c9=""
/>
role="status"
>
<svg
style="width: 100%; height: 100%; overflow: hidden;"
viewBox="0 0 50 50"
xmlns="http://www.w3.org/2000/svg"
>
<circle
cx="25"
cy="25"
fill="none"
r="20"
stroke="currentColor"
stroke-linecap="round"
stroke-width="4"
style="transform-origin: 25px 25px; animation: os-spinner-rotate 16s linear infinite,os-spinner-dash 1.5s ease-in-out infinite;"
/>
</svg>
</span>
</div>
<div
@ -5848,13 +5956,31 @@ exports[`GroupProfileSlug given a puplic group "yoga-practice" given a curre
<div
class="infinite-status-prompt"
data-v-644ea9c9=""
style="color: rgb(102, 102, 102); font-size: 14px; padding: 10px 0px; display: none;"
style="display: none;"
>
<i
class="loading-default"
data-v-46b20d22=""
<span
aria-label="Loading"
class="os-spinner inline-flex shrink-0 h-[2em] w-[2em]"
data-v-644ea9c9=""
/>
role="status"
>
<svg
style="width: 100%; height: 100%; overflow: hidden;"
viewBox="0 0 50 50"
xmlns="http://www.w3.org/2000/svg"
>
<circle
cx="25"
cy="25"
fill="none"
r="20"
stroke="currentColor"
stroke-linecap="round"
stroke-width="4"
style="transform-origin: 25px 25px; animation: os-spinner-rotate 16s linear infinite,os-spinner-dash 1.5s ease-in-out infinite;"
/>
</svg>
</span>
</div>
<div
@ -6812,13 +6938,31 @@ exports[`GroupProfileSlug given a puplic group "yoga-practice" given a curre
<div
class="infinite-status-prompt"
data-v-644ea9c9=""
style="color: rgb(102, 102, 102); font-size: 14px; padding: 10px 0px; display: none;"
style="display: none;"
>
<i
class="loading-default"
data-v-46b20d22=""
<span
aria-label="Loading"
class="os-spinner inline-flex shrink-0 h-[2em] w-[2em]"
data-v-644ea9c9=""
/>
role="status"
>
<svg
style="width: 100%; height: 100%; overflow: hidden;"
viewBox="0 0 50 50"
xmlns="http://www.w3.org/2000/svg"
>
<circle
cx="25"
cy="25"
fill="none"
r="20"
stroke="currentColor"
stroke-linecap="round"
stroke-width="4"
style="transform-origin: 25px 25px; animation: os-spinner-rotate 16s linear infinite,os-spinner-dash 1.5s ease-in-out infinite;"
/>
</svg>
</span>
</div>
<div
@ -7952,13 +8096,31 @@ exports[`GroupProfileSlug given a puplic group "yoga-practice" given a hidde
<div
class="infinite-status-prompt"
data-v-644ea9c9=""
style="color: rgb(102, 102, 102); font-size: 14px; padding: 10px 0px; display: none;"
style="display: none;"
>
<i
class="loading-default"
data-v-46b20d22=""
<span
aria-label="Loading"
class="os-spinner inline-flex shrink-0 h-[2em] w-[2em]"
data-v-644ea9c9=""
/>
role="status"
>
<svg
style="width: 100%; height: 100%; overflow: hidden;"
viewBox="0 0 50 50"
xmlns="http://www.w3.org/2000/svg"
>
<circle
cx="25"
cy="25"
fill="none"
r="20"
stroke="currentColor"
stroke-linecap="round"
stroke-width="4"
style="transform-origin: 25px 25px; animation: os-spinner-rotate 16s linear infinite,os-spinner-dash 1.5s ease-in-out infinite;"
/>
</svg>
</span>
</div>
<div
@ -9015,13 +9177,31 @@ exports[`GroupProfileSlug given a puplic group "yoga-practice" given a hidde
<div
class="infinite-status-prompt"
data-v-644ea9c9=""
style="color: rgb(102, 102, 102); font-size: 14px; padding: 10px 0px; display: none;"
style="display: none;"
>
<i
class="loading-default"
data-v-46b20d22=""
<span
aria-label="Loading"
class="os-spinner inline-flex shrink-0 h-[2em] w-[2em]"
data-v-644ea9c9=""
/>
role="status"
>
<svg
style="width: 100%; height: 100%; overflow: hidden;"
viewBox="0 0 50 50"
xmlns="http://www.w3.org/2000/svg"
>
<circle
cx="25"
cy="25"
fill="none"
r="20"
stroke="currentColor"
stroke-linecap="round"
stroke-width="4"
style="transform-origin: 25px 25px; animation: os-spinner-rotate 16s linear infinite,os-spinner-dash 1.5s ease-in-out infinite;"
/>
</svg>
</span>
</div>
<div

View File

@ -268,9 +268,9 @@
</template>
<template v-else-if="$apollo.loading">
<ds-grid-item column-span="fullWidth">
<ds-space centered>
<ds-spinner size="base"></ds-spinner>
</ds-space>
<div style="text-align: center; padding: 48px 0">
<os-spinner size="lg" />
</div>
</ds-grid-item>
</template>
<template v-else>
@ -280,7 +280,9 @@
</template>
</masonry-grid>
<client-only>
<infinite-loading v-if="hasMore" @infinite="showMoreContributions" />
<infinite-loading v-if="hasMore" @infinite="showMoreContributions">
<os-spinner slot="spinner" size="lg" />
</infinite-loading>
</client-only>
</ds-flex-item>
</ds-flex>
@ -288,7 +290,7 @@
</template>
<script>
import { OsButton, OsIcon } from '@ocelot-social/ui'
import { OsButton, OsIcon, OsSpinner } from '@ocelot-social/ui'
import { iconRegistry } from '~/utils/iconRegistry'
import uniqBy from 'lodash/uniqBy'
import { profilePagePosts } from '~/graphql/PostQuery'
@ -327,6 +329,7 @@ export default {
components: {
OsButton,
OsIcon,
OsSpinner,
AvatarUploader,
Category,
ContentViewer,

View File

@ -146,13 +146,15 @@
<!-- infinite loading -->
<client-only>
<infinite-loading v-if="hasMore" @infinite="showMoreContributions" />
<infinite-loading v-if="hasMore" @infinite="showMoreContributions">
<os-spinner slot="spinner" size="lg" />
</infinite-loading>
</client-only>
</div>
</template>
<script>
import { OsButton, OsIcon } from '@ocelot-social/ui'
import { OsButton, OsIcon, OsSpinner } from '@ocelot-social/ui'
import { iconRegistry } from '~/utils/iconRegistry'
import postListActions from '~/mixins/postListActions'
import mobile from '~/mixins/mobile'
@ -178,6 +180,7 @@ export default {
HashtagsFilter,
OsButton,
OsIcon,
OsSpinner,
PostTeaser,
HcEmpty,
MasonryGrid,

View File

@ -679,13 +679,31 @@ exports[`ProfileSlug given an authenticated user given another profile user and
<div
class="infinite-status-prompt"
data-v-644ea9c9=""
style="color: rgb(102, 102, 102); font-size: 14px; padding: 10px 0px; display: none;"
style="display: none;"
>
<i
class="loading-default"
data-v-46b20d22=""
<span
aria-label="Loading"
class="os-spinner inline-flex shrink-0 h-[2em] w-[2em]"
data-v-644ea9c9=""
/>
role="status"
>
<svg
style="width: 100%; height: 100%; overflow: hidden;"
viewBox="0 0 50 50"
xmlns="http://www.w3.org/2000/svg"
>
<circle
cx="25"
cy="25"
fill="none"
r="20"
stroke="currentColor"
stroke-linecap="round"
stroke-width="4"
style="transform-origin: 25px 25px; animation: os-spinner-rotate 16s linear infinite,os-spinner-dash 1.5s ease-in-out infinite;"
/>
</svg>
</span>
</div>
<div
@ -1447,13 +1465,31 @@ exports[`ProfileSlug given an authenticated user given another profile user and
<div
class="infinite-status-prompt"
data-v-644ea9c9=""
style="color: rgb(102, 102, 102); font-size: 14px; padding: 10px 0px; display: none;"
style="display: none;"
>
<i
class="loading-default"
data-v-46b20d22=""
<span
aria-label="Loading"
class="os-spinner inline-flex shrink-0 h-[2em] w-[2em]"
data-v-644ea9c9=""
/>
role="status"
>
<svg
style="width: 100%; height: 100%; overflow: hidden;"
viewBox="0 0 50 50"
xmlns="http://www.w3.org/2000/svg"
>
<circle
cx="25"
cy="25"
fill="none"
r="20"
stroke="currentColor"
stroke-linecap="round"
stroke-width="4"
style="transform-origin: 25px 25px; animation: os-spinner-rotate 16s linear infinite,os-spinner-dash 1.5s ease-in-out infinite;"
/>
</svg>
</span>
</div>
<div
@ -2076,13 +2112,31 @@ exports[`ProfileSlug given an authenticated user given the logged in user as pro
<div
class="infinite-status-prompt"
data-v-644ea9c9=""
style="color: rgb(102, 102, 102); font-size: 14px; padding: 10px 0px; display: none;"
style="display: none;"
>
<i
class="loading-default"
data-v-46b20d22=""
<span
aria-label="Loading"
class="os-spinner inline-flex shrink-0 h-[2em] w-[2em]"
data-v-644ea9c9=""
/>
role="status"
>
<svg
style="width: 100%; height: 100%; overflow: hidden;"
viewBox="0 0 50 50"
xmlns="http://www.w3.org/2000/svg"
>
<circle
cx="25"
cy="25"
fill="none"
r="20"
stroke="currentColor"
stroke-linecap="round"
stroke-width="4"
style="transform-origin: 25px 25px; animation: os-spinner-rotate 16s linear infinite,os-spinner-dash 1.5s ease-in-out infinite;"
/>
</svg>
</span>
</div>
<div
@ -2747,13 +2801,31 @@ exports[`ProfileSlug given an authenticated user given the logged in user as pro
<div
class="infinite-status-prompt"
data-v-644ea9c9=""
style="color: rgb(102, 102, 102); font-size: 14px; padding: 10px 0px; display: none;"
style="display: none;"
>
<i
class="loading-default"
data-v-46b20d22=""
<span
aria-label="Loading"
class="os-spinner inline-flex shrink-0 h-[2em] w-[2em]"
data-v-644ea9c9=""
/>
role="status"
>
<svg
style="width: 100%; height: 100%; overflow: hidden;"
viewBox="0 0 50 50"
xmlns="http://www.w3.org/2000/svg"
>
<circle
cx="25"
cy="25"
fill="none"
r="20"
stroke="currentColor"
stroke-linecap="round"
stroke-width="4"
style="transform-origin: 25px 25px; animation: os-spinner-rotate 16s linear infinite,os-spinner-dash 1.5s ease-in-out infinite;"
/>
</svg>
</span>
</div>
<div

View File

@ -190,9 +190,9 @@
</template>
<template v-else-if="$apollo.loading">
<ds-grid-item column-span="fullWidth">
<ds-space centered>
<ds-spinner size="base"></ds-spinner>
</ds-space>
<div style="text-align: center; padding: 48px 0">
<os-spinner size="lg" />
</div>
</ds-grid-item>
</template>
<template v-else>
@ -202,7 +202,9 @@
</template>
</masonry-grid>
<client-only>
<infinite-loading v-if="hasMore" @infinite="showMoreContributions" />
<infinite-loading v-if="hasMore" @infinite="showMoreContributions">
<os-spinner slot="spinner" size="lg" />
</infinite-loading>
</client-only>
</ds-flex-item>
</ds-flex>
@ -210,7 +212,7 @@
</template>
<script>
import { OsButton, OsIcon } from '@ocelot-social/ui'
import { OsButton, OsIcon, OsSpinner } from '@ocelot-social/ui'
import { iconRegistry } from '~/utils/iconRegistry'
import uniqBy from 'lodash/uniqBy'
import { mapGetters, mapMutations } from 'vuex'
@ -247,6 +249,7 @@ export default {
components: {
OsButton,
OsIcon,
OsSpinner,
SocialMedia,
PostTeaser,
HcFollowButton,