mirror of
https://github.com/filamentphp/filament.git
synced 2026-10-13 09:26:05 +08:00
Add React, Vue, and Svelte avatar components
This commit is contained in:
@@ -53,3 +53,69 @@ You can also pass your own custom size classes into the `size` attribute:
|
||||
size="w-12 h-12"
|
||||
/>
|
||||
```
|
||||
|
||||
## Using avatars in JavaScript components
|
||||
|
||||
You can use the same avatar markup and theme in React, Vue, and Svelte 5 custom fields, schema components, and widgets. Import the component from your Composer-installed Filament Support package using your application's Vite build. The examples below assume your component is in `resources/js/`; adjust the relative import path if it is in a subdirectory.
|
||||
|
||||
React 18 or 19, Vue 3.3.2 or later, and Svelte 5 are supported. Vue and Svelte require their respective Vite compiler plugins. The JavaScript widget, field, and schema component generators configure these for you. The components work in both JavaScript and TypeScript applications: your build compiles their TypeScript source, even if your application uses JavaScript.
|
||||
|
||||
These components use the panel's existing stylesheet. You do not need to import another stylesheet or initialize Alpine for the avatar.
|
||||
|
||||
The examples use an image at `public/images/avatar.png`. Replace the `src` with your own image URL.
|
||||
|
||||
### Using React
|
||||
|
||||
```jsx
|
||||
import React from 'react'
|
||||
import Avatar from '../../vendor/filament/support/resources/js/react/Avatar'
|
||||
|
||||
export default function Profile() {
|
||||
return (
|
||||
<Avatar
|
||||
src="/images/avatar.png"
|
||||
alt="Dan Harrin"
|
||||
circular={false}
|
||||
size="lg"
|
||||
/>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Using Vue
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import Avatar from '../../vendor/filament/support/resources/js/vue/Avatar.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Avatar
|
||||
src="/images/avatar.png"
|
||||
alt="Dan Harrin"
|
||||
:circular="false"
|
||||
size="lg"
|
||||
/>
|
||||
</template>
|
||||
```
|
||||
|
||||
### Using Svelte
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
import Avatar from '../../vendor/filament/support/resources/js/svelte/Avatar.svelte'
|
||||
</script>
|
||||
|
||||
<Avatar
|
||||
src="/images/avatar.png"
|
||||
alt="Dan Harrin"
|
||||
circular={false}
|
||||
size="lg"
|
||||
/>
|
||||
```
|
||||
|
||||
### Configuring the avatar
|
||||
|
||||
All three components default to `circular: true`, `size: 'md'`, and `alt: ''`, just like Blade. Set descriptive alternative text when the image conveys information; leave it empty when the image is decorative.
|
||||
|
||||
You can pass `sm`, `md`, `lg`, or custom CSS classes to `size`. Custom classes must be included in your theme. Native image attributes and event handlers are forwarded to the `<img>` element using your framework's conventions. Use `className` in React and `class` in Vue or Svelte to add classes alongside Filament's classes. Svelte's `class` prop accepts a space-separated string. Reactive changes to these props update the same image element.
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
export function getAvatarClasses(circular = true, size = 'md'): string {
|
||||
return [
|
||||
'fi-avatar',
|
||||
circular && 'fi-circular',
|
||||
['sm', 'md', 'lg'].includes(size) ? `fi-size-${size}` : size,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { createElement, forwardRef, type ComponentPropsWithoutRef } from 'react'
|
||||
import { getAvatarClasses } from '../components/avatar'
|
||||
|
||||
export type AvatarProps = ComponentPropsWithoutRef<'img'> & {
|
||||
circular?: boolean
|
||||
size?: string
|
||||
}
|
||||
|
||||
export default forwardRef<HTMLImageElement, AvatarProps>(function Avatar(
|
||||
{ alt = '', circular = true, size = 'md', className, ...attributes },
|
||||
ref,
|
||||
) {
|
||||
return createElement('img', {
|
||||
...attributes,
|
||||
ref,
|
||||
alt,
|
||||
className: [getAvatarClasses(circular, size), className]
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLImgAttributes } from 'svelte/elements'
|
||||
import { getAvatarClasses } from '../components/avatar'
|
||||
|
||||
let {
|
||||
alt = '',
|
||||
circular = true,
|
||||
size = 'md',
|
||||
class: className = '',
|
||||
...attributes
|
||||
}: Omit<HTMLImgAttributes, 'class'> & {
|
||||
circular?: boolean
|
||||
size?: string
|
||||
class?: string
|
||||
} = $props()
|
||||
</script>
|
||||
|
||||
<img
|
||||
{...attributes}
|
||||
{alt}
|
||||
class={[getAvatarClasses(circular, size), className]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
/>
|
||||
@@ -0,0 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import type { ImgHTMLAttributes } from 'vue'
|
||||
import { getAvatarClasses } from '../components/avatar'
|
||||
|
||||
// Vue 3.3 does not include `loading` in its native image attribute types.
|
||||
interface AvatarImageAttributes extends ImgHTMLAttributes {
|
||||
loading?: 'eager' | 'lazy'
|
||||
}
|
||||
|
||||
interface AvatarProps extends /* @vue-ignore */ AvatarImageAttributes {
|
||||
alt?: string
|
||||
circular?: boolean
|
||||
size?: string
|
||||
}
|
||||
|
||||
withDefaults(defineProps<AvatarProps>(), {
|
||||
alt: '',
|
||||
circular: true,
|
||||
size: 'md',
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<img :alt="alt" :class="getAvatarClasses(circular, size)" />
|
||||
</template>
|
||||
@@ -0,0 +1,23 @@
|
||||
import { createRef } from 'react'
|
||||
import Avatar from '../../../packages/support/resources/js/react/Avatar'
|
||||
|
||||
const image = createRef<HTMLImageElement>()
|
||||
|
||||
export const avatar = (
|
||||
<Avatar
|
||||
ref={image}
|
||||
src="/portrait.jpg"
|
||||
alt="Profile"
|
||||
loading="lazy"
|
||||
className="profile-avatar"
|
||||
circular={false}
|
||||
size="lg"
|
||||
onLoad={(event) => event.currentTarget.decode()}
|
||||
/>
|
||||
)
|
||||
|
||||
// @ts-expect-error `loading` only accepts native image loading values.
|
||||
export const invalidLoading = <Avatar loading="invalid" />
|
||||
|
||||
// @ts-expect-error `ref` refers to the image, not a wrapper.
|
||||
export const invalidRef = <Avatar ref={createRef<HTMLButtonElement>()} />
|
||||
@@ -0,0 +1,18 @@
|
||||
<script lang="ts">
|
||||
import type { ComponentProps } from 'svelte'
|
||||
import Avatar from '../../../packages/support/resources/js/svelte/Avatar.svelte'
|
||||
|
||||
// @ts-expect-error `loading` only accepts native image loading values.
|
||||
const invalidLoading: ComponentProps<typeof Avatar> = { loading: 'invalid' }
|
||||
void invalidLoading
|
||||
</script>
|
||||
|
||||
<Avatar
|
||||
src="/portrait.jpg"
|
||||
alt="Profile"
|
||||
loading="lazy"
|
||||
class="profile-avatar"
|
||||
circular={false}
|
||||
size="lg"
|
||||
onload={(event) => event.preventDefault()}
|
||||
/>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import Avatar from '../../../packages/support/resources/js/vue/Avatar.vue'
|
||||
|
||||
type AvatarProps = InstanceType<typeof Avatar>['$props']
|
||||
|
||||
// @ts-expect-error `loading` only accepts native image loading values.
|
||||
const invalidLoading: AvatarProps = { loading: 'invalid' }
|
||||
void invalidLoading
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Avatar
|
||||
src="/portrait.jpg"
|
||||
alt="Profile"
|
||||
loading="lazy"
|
||||
class="profile-avatar"
|
||||
:circular="false"
|
||||
size="lg"
|
||||
@load="(event) => event.preventDefault()"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,35 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { test } from 'node:test'
|
||||
import { build } from 'esbuild'
|
||||
|
||||
const result = await build({
|
||||
entryPoints: ['packages/support/resources/js/components/avatar.ts'],
|
||||
format: 'esm',
|
||||
write: false,
|
||||
})
|
||||
const { getAvatarClasses } = await import(
|
||||
`data:text/javascript;base64,${Buffer.from(result.outputFiles[0].text).toString('base64')}`
|
||||
)
|
||||
|
||||
test('avatar defaults match Blade', () => {
|
||||
assert.equal(getAvatarClasses(), 'fi-avatar fi-circular fi-size-md')
|
||||
})
|
||||
|
||||
test('avatar rounding and sizes are independent', () => {
|
||||
for (const size of ['sm', 'md', 'lg']) {
|
||||
assert.equal(getAvatarClasses(false, size), `fi-avatar fi-size-${size}`)
|
||||
assert.equal(
|
||||
getAvatarClasses(true, size),
|
||||
`fi-avatar fi-circular fi-size-${size}`,
|
||||
)
|
||||
}
|
||||
assert.equal(
|
||||
getAvatarClasses(false, 'profile-avatar'),
|
||||
'fi-avatar profile-avatar',
|
||||
)
|
||||
assert.equal(
|
||||
getAvatarClasses(true, 'w-12 h-12'),
|
||||
'fi-avatar fi-circular w-12 h-12',
|
||||
)
|
||||
assert.equal(getAvatarClasses(false, ''), 'fi-avatar')
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"vueCompilerOptions": {
|
||||
"strictTemplates": true
|
||||
},
|
||||
"include": [
|
||||
"avatar-consumers/**/*",
|
||||
"../../packages/support/resources/js/react/Avatar.tsx",
|
||||
"../../packages/support/resources/js/vue/Avatar.vue",
|
||||
"../../packages/support/resources/js/svelte/Avatar.svelte"
|
||||
]
|
||||
}
|
||||
@@ -41,6 +41,30 @@ await buildViteRenderers(
|
||||
'build/js-field-renderers',
|
||||
)
|
||||
|
||||
await buildViteRenderers(
|
||||
{ avatar: 'tests/js/renderers/avatar.svelte.js' },
|
||||
'build/js-avatar-renderers',
|
||||
)
|
||||
|
||||
for (const [command, arguments_] of [
|
||||
[
|
||||
'node_modules/typescript/bin/tsc',
|
||||
['--project', 'tests/js/avatar.tsconfig.json'],
|
||||
],
|
||||
[
|
||||
'node_modules/vue-tsc/bin/vue-tsc.js',
|
||||
['--project', 'tests/js/avatar.tsconfig.json'],
|
||||
],
|
||||
[
|
||||
'node_modules/svelte-check/bin/svelte-check',
|
||||
['--tsconfig', 'tests/js/avatar.tsconfig.json', '--fail-on-warnings'],
|
||||
],
|
||||
]) {
|
||||
execFileSync(process.execPath, [command, ...arguments_], {
|
||||
stdio: 'inherit',
|
||||
})
|
||||
}
|
||||
|
||||
// Plugin authors can also bundle plain JavaScript and React without Vite.
|
||||
const result = await build({
|
||||
entryPoints: [
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { createElement } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { createApp, h, shallowRef } from 'vue'
|
||||
import { mount, unmount } from 'svelte'
|
||||
import ReactAvatar from '../../../packages/support/resources/js/react/Avatar'
|
||||
import VueAvatar from '../../../packages/support/resources/js/vue/Avatar.vue'
|
||||
import SvelteAvatar from '../../../packages/support/resources/js/svelte/Avatar.svelte'
|
||||
|
||||
export default function mountAvatars(host, framework, cases) {
|
||||
const renderers = cases.map((attributes) => {
|
||||
const container = document.createElement('span')
|
||||
host.append(container)
|
||||
const reportLoad = () => container.setAttribute('data-loaded', 'true')
|
||||
|
||||
if (framework === 'react') {
|
||||
const root = createRoot(container)
|
||||
const update = ({ class: className, ...props }) =>
|
||||
root.render(
|
||||
createElement(ReactAvatar, {
|
||||
...props,
|
||||
className,
|
||||
onLoad: reportLoad,
|
||||
ref: (image) => {
|
||||
if (image instanceof HTMLImageElement) {
|
||||
container.setAttribute('data-image-ref', 'true')
|
||||
}
|
||||
},
|
||||
}),
|
||||
)
|
||||
update(attributes)
|
||||
return { update, destroy: () => root.unmount() }
|
||||
}
|
||||
|
||||
if (framework === 'vue') {
|
||||
const props = shallowRef(attributes)
|
||||
const application = createApp({
|
||||
render: () =>
|
||||
h(VueAvatar, { ...props.value, onLoad: reportLoad }),
|
||||
})
|
||||
application.mount(container)
|
||||
return {
|
||||
update: (attributes) => {
|
||||
props.value = attributes
|
||||
},
|
||||
destroy: () => application.unmount(),
|
||||
}
|
||||
}
|
||||
|
||||
const props = $state({ ...attributes, onload: reportLoad })
|
||||
const component = mount(SvelteAvatar, { target: container, props })
|
||||
return {
|
||||
update: (attributes) => {
|
||||
for (const key of Object.keys(props)) {
|
||||
if (key !== 'onload' && !(key in attributes))
|
||||
delete props[key]
|
||||
}
|
||||
Object.assign(props, attributes)
|
||||
},
|
||||
destroy: () => unmount(component),
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
update() {
|
||||
renderers.forEach((renderer, index) =>
|
||||
renderer.update({
|
||||
...cases[index],
|
||||
alt: 'Updated portrait',
|
||||
circular: false,
|
||||
size: 'avatar-custom-size',
|
||||
class: 'avatar-custom-theme',
|
||||
}),
|
||||
)
|
||||
},
|
||||
reset() {
|
||||
renderers.forEach((renderer, index) =>
|
||||
renderer.update({ src: cases[index].src }),
|
||||
)
|
||||
},
|
||||
destroy: () => renderers.forEach((renderer) => renderer.destroy()),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
@php
|
||||
use Filament\Support\Facades\FilamentAsset;
|
||||
use Illuminate\View\ComponentAttributeBag;
|
||||
@endphp
|
||||
|
||||
<x-filament-panels::page>
|
||||
<style>
|
||||
[data-avatar-comparison] {
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
}
|
||||
[data-avatar-row] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
min-height: 64px;
|
||||
}
|
||||
.avatar-custom-size {
|
||||
width: 56px;
|
||||
height: 48px;
|
||||
}
|
||||
.avatar-custom-theme {
|
||||
outline: 3px solid var(--primary-500);
|
||||
}
|
||||
</style>
|
||||
|
||||
<div data-avatar-comparison>
|
||||
<section>
|
||||
<h2>Blade</h2>
|
||||
<div data-avatar-row="blade">
|
||||
@foreach ($this->getAvatarCases() as $attributes)
|
||||
<x-filament::avatar
|
||||
:attributes="new ComponentAttributeBag($attributes)"
|
||||
/>
|
||||
@endforeach
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@foreach (['react', 'vue', 'svelte'] as $framework)
|
||||
<section
|
||||
x-data="{
|
||||
renderer: null,
|
||||
isDestroyed: false,
|
||||
async init() {
|
||||
const { default: mount } = await import(@js(FilamentAsset::getScriptSrc('avatar', 'tests/avatars')))
|
||||
if (this.isDestroyed) return
|
||||
this.renderer = mount(this.$refs.host, @js($framework), @js($this->getAvatarCases()))
|
||||
},
|
||||
destroy() {
|
||||
this.isDestroyed = true
|
||||
this.renderer?.destroy()
|
||||
},
|
||||
}"
|
||||
>
|
||||
<h2>{{ ucfirst($framework) }}</h2>
|
||||
<div
|
||||
wire:ignore
|
||||
x-ref="host"
|
||||
data-avatar-row="{{ $framework }}"
|
||||
></div>
|
||||
<x-filament::button
|
||||
color="gray"
|
||||
data-testid="update-{{ $framework }}"
|
||||
x-on:click="renderer.update()"
|
||||
>
|
||||
Update {{ ucfirst($framework) }} avatars
|
||||
</x-filament::button>
|
||||
<x-filament::button
|
||||
color="gray"
|
||||
data-testid="reset-{{ $framework }}"
|
||||
x-on:click="renderer.reset()"
|
||||
>
|
||||
Reset {{ ucfirst($framework) }} avatars
|
||||
</x-filament::button>
|
||||
</section>
|
||||
@endforeach
|
||||
</div>
|
||||
</x-filament-panels::page>
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace Filament\Tests\Fixtures\Pages;
|
||||
|
||||
use Filament\Pages\Page;
|
||||
|
||||
class AvatarBrowserTest extends Page
|
||||
{
|
||||
protected string $view = 'pages.avatar-browser-test';
|
||||
|
||||
protected static bool $shouldRegisterNavigation = false;
|
||||
|
||||
/** @return array<array<string, mixed>> */
|
||||
public function getAvatarCases(): array
|
||||
{
|
||||
$source = 'data:image/svg+xml,' . rawurlencode('<svg xmlns="http://www.w3.org/2000/svg" width="80" height="80"><rect width="80" height="80" fill="#6366f1"/><circle cx="40" cy="28" r="14" fill="#e0e7ff"/><ellipse cx="40" cy="75" rx="28" ry="27" fill="#e0e7ff"/></svg>');
|
||||
|
||||
return array_map(static fn (array $attributes): array => [
|
||||
'src' => $source,
|
||||
...$attributes,
|
||||
], [
|
||||
[],
|
||||
['alt' => 'Small portrait', 'size' => 'sm', 'circular' => false],
|
||||
['alt' => 'Large portrait', 'size' => 'lg', 'title' => 'Profile', 'loading' => 'eager', 'data-profile' => '42'],
|
||||
['alt' => 'Custom portrait', 'size' => 'avatar-custom-size', 'class' => 'avatar-custom-theme', 'circular' => false],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ use Filament\Tests\Fixtures\Pages\AutofocusBasicBrowserTest;
|
||||
use Filament\Tests\Fixtures\Pages\AutofocusBrowserTest;
|
||||
use Filament\Tests\Fixtures\Pages\AutofocusSecondTabBrowserTest;
|
||||
use Filament\Tests\Fixtures\Pages\AutofocusWizardBrowserTest;
|
||||
use Filament\Tests\Fixtures\Pages\AvatarBrowserTest;
|
||||
use Filament\Tests\Fixtures\Pages\BuilderTest;
|
||||
use Filament\Tests\Fixtures\Pages\CalloutBrowserTest;
|
||||
use Filament\Tests\Fixtures\Pages\CheckboxListTest;
|
||||
@@ -127,6 +128,7 @@ class AdminPanelProvider extends PanelProvider
|
||||
AutofocusBrowserTest::class,
|
||||
AutofocusSecondTabBrowserTest::class,
|
||||
AutofocusWizardBrowserTest::class,
|
||||
AvatarBrowserTest::class,
|
||||
BuilderTest::class,
|
||||
CalloutBrowserTest::class,
|
||||
CheckboxListTest::class,
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
use Filament\Support\Assets\Js;
|
||||
use Filament\Support\Facades\FilamentAsset;
|
||||
use Filament\Tests\Fixtures\Models\User;
|
||||
use Filament\Tests\TestCase;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
use function Pest\Laravel\actingAs;
|
||||
|
||||
uses(TestCase::class)->group('serial');
|
||||
|
||||
beforeEach(function (): void {
|
||||
foreach (File::allFiles(dirname(__DIR__, 4) . '/build/js-avatar-renderers') as $file) {
|
||||
$id = substr(str_replace('\\', '/', $file->getRelativePathname()), 0, -3);
|
||||
FilamentAsset::register([
|
||||
Js::make($id, $file->getPathname())->loadedOnRequest(),
|
||||
], 'tests/avatars');
|
||||
}
|
||||
|
||||
Artisan::call('filament:assets');
|
||||
actingAs(User::factory()->create());
|
||||
});
|
||||
|
||||
it('renders `Avatar` like Blade and updates props in all frameworks', function (): void {
|
||||
foreach (['light', 'dark'] as $theme) {
|
||||
$page = visit('/avatar-browser-test');
|
||||
|
||||
if ($theme === 'dark') {
|
||||
$page = $page->inDarkMode();
|
||||
}
|
||||
|
||||
$page->assertScript('document.documentElement.classList.contains("dark")', $theme === 'dark');
|
||||
|
||||
foreach (['react', 'vue', 'svelte'] as $framework) {
|
||||
$page->assertScript("document.querySelectorAll('[data-avatar-row={$framework}] [data-loaded]').length", 4);
|
||||
}
|
||||
|
||||
$page->assertScript("document.querySelectorAll('[data-avatar-row=react] [data-image-ref]').length", 4);
|
||||
|
||||
$page->assertScript(<<<'JS'
|
||||
(() => {
|
||||
const attributes = (image) => Object.fromEntries(
|
||||
[...image.attributes].map(({ name, value }) => [name,
|
||||
name === 'class' ? value.split(/\s+/).filter(Boolean).sort().join(' ') : value,
|
||||
]).sort(([first], [second]) => first.localeCompare(second)),
|
||||
)
|
||||
const images = (framework) => [...document.querySelectorAll(`[data-avatar-row=${framework}] img`)].map(attributes)
|
||||
return ['react', 'vue', 'svelte'].every((framework) => JSON.stringify(images(framework)) === JSON.stringify(images('blade')))
|
||||
})()
|
||||
JS, true)
|
||||
->assertNoAccessibilityIssues();
|
||||
|
||||
$page->script("window.avatarImages = [...document.querySelectorAll('[data-avatar-row] img')]");
|
||||
|
||||
foreach (['react', 'vue', 'svelte'] as $framework) {
|
||||
$page->click("[data-testid=update-{$framework}]")
|
||||
->assertScript("[...document.querySelectorAll('[data-avatar-row={$framework}] img')].every(image => image.alt === 'Updated portrait' && !image.classList.contains('fi-circular') && image.classList.contains('avatar-custom-size') && image.classList.contains('avatar-custom-theme'))", true);
|
||||
}
|
||||
|
||||
$page->assertScript("[...document.querySelectorAll('[data-avatar-row] img')].every((image, index) => image === window.avatarImages[index])", true)
|
||||
->assertNoSmoke()
|
||||
->assertNoAccessibilityIssues();
|
||||
|
||||
foreach (['react', 'vue', 'svelte'] as $framework) {
|
||||
$page->click("[data-testid=reset-{$framework}]")
|
||||
->assertScript("[...document.querySelectorAll('[data-avatar-row={$framework}] img')].every(image => image.alt === '' && image.className === 'fi-avatar fi-circular fi-size-md' && !image.hasAttribute('title') && !image.hasAttribute('loading') && !image.hasAttribute('data-profile'))", true);
|
||||
}
|
||||
|
||||
$page->assertScript("[...document.querySelectorAll('[data-avatar-row] img')].every((image, index) => image === window.avatarImages[index])", true)
|
||||
->assertNoSmoke()
|
||||
->assertNoAccessibilityIssues();
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user