diff --git a/docs/12-components/03-avatar.md b/docs/12-components/03-avatar.md index bf2f8f24aa..7fde08ea60 100644 --- a/docs/12-components/03-avatar.md +++ b/docs/12-components/03-avatar.md @@ -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 ( + + ) +} +``` + +### Using Vue + +```vue + + + +``` + +### Using Svelte + +```svelte + + + +``` + +### 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 `` 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. diff --git a/packages/support/resources/js/components/avatar.ts b/packages/support/resources/js/components/avatar.ts new file mode 100644 index 0000000000..71f89dab6a --- /dev/null +++ b/packages/support/resources/js/components/avatar.ts @@ -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(' ') +} diff --git a/packages/support/resources/js/react/Avatar.tsx b/packages/support/resources/js/react/Avatar.tsx new file mode 100644 index 0000000000..0298e71a2d --- /dev/null +++ b/packages/support/resources/js/react/Avatar.tsx @@ -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(function Avatar( + { alt = '', circular = true, size = 'md', className, ...attributes }, + ref, +) { + return createElement('img', { + ...attributes, + ref, + alt, + className: [getAvatarClasses(circular, size), className] + .filter(Boolean) + .join(' '), + }) +}) diff --git a/packages/support/resources/js/svelte/Avatar.svelte b/packages/support/resources/js/svelte/Avatar.svelte new file mode 100644 index 0000000000..01b26a5be5 --- /dev/null +++ b/packages/support/resources/js/svelte/Avatar.svelte @@ -0,0 +1,24 @@ + + + diff --git a/packages/support/resources/js/vue/Avatar.vue b/packages/support/resources/js/vue/Avatar.vue new file mode 100644 index 0000000000..3651c6dd7a --- /dev/null +++ b/packages/support/resources/js/vue/Avatar.vue @@ -0,0 +1,25 @@ + + + diff --git a/tests/js/avatar-consumers/React.tsx b/tests/js/avatar-consumers/React.tsx new file mode 100644 index 0000000000..014b4b0403 --- /dev/null +++ b/tests/js/avatar-consumers/React.tsx @@ -0,0 +1,23 @@ +import { createRef } from 'react' +import Avatar from '../../../packages/support/resources/js/react/Avatar' + +const image = createRef() + +export const avatar = ( + event.currentTarget.decode()} + /> +) + +// @ts-expect-error `loading` only accepts native image loading values. +export const invalidLoading = + +// @ts-expect-error `ref` refers to the image, not a wrapper. +export const invalidRef = ()} /> diff --git a/tests/js/avatar-consumers/Svelte.svelte b/tests/js/avatar-consumers/Svelte.svelte new file mode 100644 index 0000000000..7a371998cb --- /dev/null +++ b/tests/js/avatar-consumers/Svelte.svelte @@ -0,0 +1,18 @@ + + + event.preventDefault()} +/> diff --git a/tests/js/avatar-consumers/Vue.vue b/tests/js/avatar-consumers/Vue.vue new file mode 100644 index 0000000000..d1ad04b97a --- /dev/null +++ b/tests/js/avatar-consumers/Vue.vue @@ -0,0 +1,21 @@ + + + diff --git a/tests/js/avatar.test.js b/tests/js/avatar.test.js new file mode 100644 index 0000000000..e554666d83 --- /dev/null +++ b/tests/js/avatar.test.js @@ -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') +}) diff --git a/tests/js/avatar.tsconfig.json b/tests/js/avatar.tsconfig.json new file mode 100644 index 0000000000..6cb46a860e --- /dev/null +++ b/tests/js/avatar.tsconfig.json @@ -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" + ] +} diff --git a/tests/js/build-renderers.js b/tests/js/build-renderers.js index 24682ecdc7..d045fc9e1e 100644 --- a/tests/js/build-renderers.js +++ b/tests/js/build-renderers.js @@ -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: [ diff --git a/tests/js/renderers/avatar.svelte.js b/tests/js/renderers/avatar.svelte.js new file mode 100644 index 0000000000..9781195684 --- /dev/null +++ b/tests/js/renderers/avatar.svelte.js @@ -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()), + } +} diff --git a/tests/resources/views/pages/avatar-browser-test.blade.php b/tests/resources/views/pages/avatar-browser-test.blade.php new file mode 100644 index 0000000000..fcfbf6156b --- /dev/null +++ b/tests/resources/views/pages/avatar-browser-test.blade.php @@ -0,0 +1,78 @@ +@php + use Filament\Support\Facades\FilamentAsset; + use Illuminate\View\ComponentAttributeBag; +@endphp + + + + +
+
+

Blade

+
+ @foreach ($this->getAvatarCases() as $attributes) + + @endforeach +
+
+ + @foreach (['react', 'vue', 'svelte'] as $framework) +
+

{{ ucfirst($framework) }}

+
+ + Update {{ ucfirst($framework) }} avatars + + + Reset {{ ucfirst($framework) }} avatars + +
+ @endforeach +
+
diff --git a/tests/src/Fixtures/Pages/AvatarBrowserTest.php b/tests/src/Fixtures/Pages/AvatarBrowserTest.php new file mode 100644 index 0000000000..6fc265086c --- /dev/null +++ b/tests/src/Fixtures/Pages/AvatarBrowserTest.php @@ -0,0 +1,28 @@ +> */ + public function getAvatarCases(): array + { + $source = 'data:image/svg+xml,' . rawurlencode(''); + + 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], + ]); + } +} diff --git a/tests/src/Fixtures/Providers/AdminPanelProvider.php b/tests/src/Fixtures/Providers/AdminPanelProvider.php index 4074d5a54a..3d29a29f6a 100644 --- a/tests/src/Fixtures/Providers/AdminPanelProvider.php +++ b/tests/src/Fixtures/Providers/AdminPanelProvider.php @@ -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, diff --git a/tests/src/Support/Components/AvatarBrowserTest.php b/tests/src/Support/Components/AvatarBrowserTest.php new file mode 100644 index 0000000000..1652e4d7a9 --- /dev/null +++ b/tests/src/Support/Components/AvatarBrowserTest.php @@ -0,0 +1,75 @@ +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(); + } +});