mirror of
https://github.com/IT4Change/Ocelot-Social.git
synced 2026-03-01 12:44:28 +00:00
refactor(package/ui): os-spinner (#9245)
This commit is contained in:
parent
c3a65a410e
commit
daafde24b0
@ -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 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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']>
|
||||
|
||||
@ -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'
|
||||
|
||||
149
packages/ui/src/components/OsSpinner/OsSpinner.spec.ts
Normal file
149
packages/ui/src/components/OsSpinner/OsSpinner.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
113
packages/ui/src/components/OsSpinner/OsSpinner.stories.ts
Normal file
113
packages/ui/src/components/OsSpinner/OsSpinner.stories.ts
Normal 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>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
101
packages/ui/src/components/OsSpinner/OsSpinner.visual.spec.ts
Normal file
101
packages/ui/src/components/OsSpinner/OsSpinner.visual.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
120
packages/ui/src/components/OsSpinner/OsSpinner.vue
Normal file
120
packages/ui/src/components/OsSpinner/OsSpinner.vue
Normal 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 |
2
packages/ui/src/components/OsSpinner/index.ts
Normal file
2
packages/ui/src/components/OsSpinner/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { default as OsSpinner } from './OsSpinner.vue'
|
||||
export { SPINNER_SIZES } from './spinner.variants'
|
||||
13
packages/ui/src/components/OsSpinner/spinner.variants.ts
Normal file
13
packages/ui/src/components/OsSpinner/spinner.variants.ts
Normal 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]',
|
||||
}
|
||||
@ -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'
|
||||
|
||||
12
packages/ui/src/types.d.ts
vendored
12
packages/ui/src/types.d.ts
vendored
@ -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: sm–xl (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'
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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 />',
|
||||
}))
|
||||
@ -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>
|
||||
@ -1,37 +1,20 @@
|
||||
<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 v-if="$apollo.loading">
|
||||
<div style="text-align: center; padding: 48px 0">
|
||||
<os-spinner size="xl" />
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="error">
|
||||
<ds-space centered>
|
||||
<ds-space>
|
||||
<img :src="errorIconPath" width="40" />
|
||||
</ds-space>
|
||||
<ds-text>
|
||||
{{ $t('site.error-occurred') }}
|
||||
</ds-text>
|
||||
</ds-space>
|
||||
</template>
|
||||
<template v-else-if="data">
|
||||
<template v-else-if="statistics">
|
||||
<ds-space margin="large">
|
||||
<ds-flex>
|
||||
<ds-flex-item
|
||||
v-for="(value, name, index) in filterStatistics(data.statistics)"
|
||||
:key="index"
|
||||
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
|
||||
>
|
||||
<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>
|
||||
@ -41,29 +24,47 @@
|
||||
</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>
|
||||
</ApolloQuery>
|
||||
</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
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user