diff --git a/packages/theme/src/components/avatar.ts b/packages/theme/src/components/avatar.ts new file mode 100644 index 00000000..bfb76ab2 --- /dev/null +++ b/packages/theme/src/components/avatar.ts @@ -0,0 +1,52 @@ +import { tv, type VariantProps } from '../tw'; +const avatar = tv({ + slots: { + base: [ + 'flex', + 'relative', + 'justify-center', + 'items-center', + 'box-border', + 'overflow-hidden', + 'align-middle', + 'text-foreground', + 'z-0', + 'bg-default-200', + 'ring-1 ring-default ring-offset-1 ring-offset-background' + ], + img: 'size-full', + name: [ + 'w-full', + 'font-normal', + 'text-center', + 'text-inherit', + 'absolute', + 'top-1/2', + 'left-1/2', + '-translate-x-1/2', + '-translate-y-1/2', + 'select-none' + ] + }, + variants: { + shape: { + square: 'rounded-[20%]', + circle: 'rounded-full' + }, + size: { + xs: { base: 'size-5 text-xs' }, + sm: { base: 'size-6 text-sm' }, + md: { base: 'size-8 text-base' }, + lg: { base: 'size-10 text-lg' }, + xl: { base: 'size-12 text-xl' } + } + }, + defaultVariants: { + size: 'md', + shape: 'circle' + } +}); + +export type AvatarVariants = VariantProps; +export type AvatarSlots = keyof ReturnType; +export { avatar }; diff --git a/packages/theme/src/components/index.ts b/packages/theme/src/components/index.ts index 0f3b60e4..28f5e4c5 100644 --- a/packages/theme/src/components/index.ts +++ b/packages/theme/src/components/index.ts @@ -1,3 +1,4 @@ +export * from './avatar'; export * from './button'; export * from './chip'; export * from './progress-bar'; diff --git a/packages/utilities/package.json b/packages/utilities/package.json index d1389c84..107c1a31 100644 --- a/packages/utilities/package.json +++ b/packages/utilities/package.json @@ -100,6 +100,7 @@ "type": "addon", "main": "addon-main.js", "app-js": { + "./components/avatar.js": "./dist/_app_/components/avatar.js", "./components/collapsible.js": "./dist/_app_/components/collapsible.js", "./components/divider.js": "./dist/_app_/components/divider.js", "./components/spinner.js": "./dist/_app_/components/spinner.js", diff --git a/packages/utilities/src/components/avatar.gts b/packages/utilities/src/components/avatar.gts new file mode 100644 index 00000000..a9a3d3b1 --- /dev/null +++ b/packages/utilities/src/components/avatar.gts @@ -0,0 +1,139 @@ +import Component from '@glimmer/component'; +import { + useStyles, + type AvatarVariants, + type AvatarSlots, + type SlotsToClasses +} from '@frontile/theme'; + +interface AvatarSignature { + Args: { + /** + * Controls the size of the avatar. + * + * @defaultValue 'md' + */ + size?: AvatarVariants['size']; + + /** + * Defines the shape of the avatar. + * + * @defaultValue 'circle' + */ + shape?: AvatarVariants['shape']; + + /** + * URL of the image to be displayed in the avatar. + * If provided, the image will be used instead of initials. + * + */ + src?: string | null; + + /** + * Full name of the user, used to generate initials. + * If `@firstName` and `@lastName` are not provided, initials will be + * derived from this property. + */ + name?: string; + + /** + * First name of the user, used to generate initials. + * If `@name` is not provided, initials will be generated from + * `@firstName` and `@lastName`. + */ + firstName?: string; + + /** + * Last name of the user, used to generate initials. + * If `@name` is not provided, initials will be generated from + * `@firstName` and `@lastName`. + */ + lastName?: string; + + /** + * Alternative text for accessibility. + * If `@src` is provided, this text will be used as the `alt` + * attribute for the image. + * If only initials are displayed, this text will be read by screen readers. + */ + alt?: string; + + /** + * Custom CSS classes for styling different slots within the avatar component. + */ + classes?: SlotsToClasses; + }; + + /** + * The root element of the avatar component, which is an HTML `` tag. + */ + Element: HTMLSpanElement; +} + +class Avatar extends Component { + get classes() { + const { avatar } = useStyles(); + return avatar({ + size: this.args.size, + shape: this.args.shape + }); + } + + get initials() { + const { name, firstName, lastName } = this.args; + + if (name) { + return name + .trim() + .split(/\s+/) + .slice(0, 2) + .map((word) => word.charAt(0)) + .join('') + .toUpperCase(); + } + + const initials = [firstName?.charAt(0), lastName?.charAt(0)] + .filter(Boolean) + .join('') + .toUpperCase(); + + return initials; + } + + get shouldShowInitials() { + const { src, name, firstName, lastName } = this.args; + if (src) { + return false; + } + + return Boolean(name || firstName || lastName); + } + + +} + +export { Avatar }; +export default Avatar; diff --git a/packages/utilities/src/components/avatar.md b/packages/utilities/src/components/avatar.md new file mode 100644 index 00000000..43ff08bf --- /dev/null +++ b/packages/utilities/src/components/avatar.md @@ -0,0 +1,119 @@ +--- +label: New +--- + +# Avatar + +The Avatar component is used to represent a user by displaying either their initials or an image. It supports customization of size and shape. + +## Import + +```js +import { Avatar } from '@frontile/utilities'; +``` + +## Usage + +### Basic + +By default, the Avatar component will display initials derived from the `@name`, `@firstName`, and `@lastName` arguments. + +```gts preview +import { Avatar } from '@frontile/utilities'; + + +``` + +### With an Image + +If an `@src` is provided, the avatar will display the image instead of initials. + +```gts preview +import { Avatar } from '@frontile/utilities'; + + +``` + +### Different Sizes + +The `@size` property allows you to customize the avatar's size. + +```gts preview +import { Avatar } from '@frontile/utilities'; + + +``` + +### Shapes + +The `@shape` property changes the avatar shape. + +```gts preview +import { Avatar } from '@frontile/utilities'; + + +``` + +### Accessibility + +To provide better accessibility, the `@alt` attribute should be used when displaying an image. + +```gts preview +import { Avatar } from '@frontile/utilities'; + + +``` + +If no `@alt` is provided, screen readers will read the initials. + +### Custom Styling + +You can pass custom `@classes` to override styling: + +```gts preview +import { Avatar } from '@frontile/utilities'; +import { hash } from '@ember/helper'; + + +``` + +## API + + diff --git a/packages/utilities/src/index.ts b/packages/utilities/src/index.ts index 8fc257f6..c31ef080 100644 --- a/packages/utilities/src/index.ts +++ b/packages/utilities/src/index.ts @@ -1,5 +1,6 @@ import 'focus-visible/dist/focus-visible.js'; +export * from './components/avatar'; export * from './components/visually-hidden'; export * from './components/collapsible'; export * from './components/divider'; diff --git a/site/app/components/signature-data.ts b/site/app/components/signature-data.ts index ad1637f0..2f242c01 100644 --- a/site/app/components/signature-data.ts +++ b/site/app/components/signature-data.ts @@ -3861,7 +3861,8 @@ const data: ComponentDoc[] = [ isInternal: false, description: 'Sets the initial selected state of the Switch when used in uncontrolled mode.', - tags: {} + tags: { defaultValue: { name: 'defaultValue', value: 'false' } }, + defaultValue: 'false' }, { identifier: 'description', @@ -3899,7 +3900,8 @@ const data: ComponentDoc[] = [ isRequired: false, isInternal: false, description: 'The visual intent (e.g., color or style) of the Switch.', - tags: {} + tags: { defaultValue: { name: 'defaultValue', value: "'primary'" } }, + defaultValue: '\'primary\'' }, { identifier: 'isDisabled', @@ -9235,6 +9237,106 @@ const data: ComponentDoc[] = [ description: '', tags: {} }, + { + package: 'utilities', + module: 'avatar', + name: 'Avatar', + fileName: 'packages/utilities/declarations/components/avatar.d.ts', + Args: [ + { + identifier: 'alt', + type: { type: 'string' }, + isRequired: false, + isInternal: false, + description: + 'Alternative text for accessibility.\nIf `@src` is provided, this text will be used as the `alt`\nattribute for the image.\nIf only initials are displayed, this text will be read by screen readers.', + tags: {} + }, + { + identifier: 'classes', + type: { + type: 'SlotsToClasses<\'name\' | \'base\' | \'img\'>' + }, + isRequired: false, + isInternal: false, + description: + 'Custom CSS classes for styling different slots within the avatar component.', + tags: {} + }, + { + identifier: 'firstName', + type: { type: 'string' }, + isRequired: false, + isInternal: false, + description: + 'First name of the user, used to generate initials.\nIf `@name` is not provided, initials will be generated from\n`@firstName` and `@lastName`.', + tags: {} + }, + { + identifier: 'lastName', + type: { type: 'string' }, + isRequired: false, + isInternal: false, + description: + 'Last name of the user, used to generate initials.\nIf `@name` is not provided, initials will be generated from\n`@firstName` and `@lastName`.', + tags: {} + }, + { + identifier: 'name', + type: { type: 'string' }, + isRequired: false, + isInternal: false, + description: + 'Full name of the user, used to generate initials.\nIf `@firstName` and `@lastName` are not provided, initials will be\nderived from this property.', + tags: {} + }, + { + identifier: 'shape', + type: { + type: 'enum', + raw: '\'square\' | \'circle\'', + items: ["'square'", "'circle'"] + }, + isRequired: false, + isInternal: false, + description: 'Defines the shape of the avatar.', + tags: { defaultValue: { name: 'defaultValue', value: "'circle'" } }, + defaultValue: '\'circle\'' + }, + { + identifier: 'size', + type: { + type: 'enum', + raw: '\'xs\' | \'sm\' | \'lg\' | \'xl\' | \'md\'', + items: ["'xs'", "'sm'", "'lg'", "'xl'", "'md'"] + }, + isRequired: false, + isInternal: false, + description: 'Controls the size of the avatar.', + tags: { defaultValue: { name: 'defaultValue', value: "'md'" } }, + defaultValue: '\'md\'' + }, + { + identifier: 'src', + type: { type: 'string' }, + isRequired: false, + isInternal: false, + description: + 'URL of the image to be displayed in the avatar.\nIf provided, the image will be used instead of initials.', + tags: {} + } + ], + Blocks: [], + Element: { + identifier: 'Element', + type: { type: 'HTMLSpanElement' }, + description: + 'The root element of the avatar component, which is an HTML `` tag.', + url: 'https://developer.mozilla.org/en-US/docs/Web/API/HTMLSpanElement' + }, + description: '', + tags: {} + }, { package: 'utilities', module: 'collapsible', diff --git a/test-app/app/components/core/collapsible-demo.hbs b/test-app/app/components/core/collapsible-demo.hbs deleted file mode 100644 index 6fa9c7c5..00000000 --- a/test-app/app/components/core/collapsible-demo.hbs +++ /dev/null @@ -1,17 +0,0 @@ -
- - - -
- Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse malesuada lacus ex, sit amet blandit leo lobortis eget. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse malesuada lacus ex, sit amet blandit leo lobortis eget. -
-
- -
\ No newline at end of file diff --git a/test-app/app/components/core/collapsible-demo.ts b/test-app/app/components/core/collapsible-demo.ts deleted file mode 100644 index 04c8896d..00000000 --- a/test-app/app/components/core/collapsible-demo.ts +++ /dev/null @@ -1,13 +0,0 @@ -import Component from '@glimmer/component'; -import { tracked } from '@glimmer/tracking'; -import { action } from '@ember/object'; - -interface CollapsibleDemoArgs {} - -export default class CollapsibleDemo extends Component { - @tracked isOpen = false; - - @action toggle(): void { - this.isOpen = !this.isOpen; - } -} diff --git a/test-app/app/components/core/demo.hbs b/test-app/app/components/core/demo.hbs deleted file mode 100644 index 9de58f33..00000000 --- a/test-app/app/components/core/demo.hbs +++ /dev/null @@ -1,48 +0,0 @@ -
- -
- This should not be shown - -

- CloseButton -

-
- - - - - -
- - - -

- Spinner -

-
- - - - - -
- -
- - - - - -
- - - -

- Collapsible -

- - - - {{outlet}} -
-
\ No newline at end of file diff --git a/test-app/app/components/utilities.gts b/test-app/app/components/utilities.gts new file mode 100644 index 00000000..ceb4dec6 --- /dev/null +++ b/test-app/app/components/utilities.gts @@ -0,0 +1,148 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { on } from '@ember/modifier'; +import { Button, CloseButton } from '@frontile/buttons'; +import { + Avatar, + Collapsible, + Divider, + Spinner, + VisuallyHidden +} from '@frontile/utilities'; +import type { TOC } from '@ember/component/template-only'; + +class CollapsibleDemo extends Component { + @tracked isOpen = false; + + toggle = () => { + this.isOpen = !this.isOpen; + }; + + +} + +const Title: TOC<{ + Element: HTMLHeadingElement; + Blocks: { + default: []; + }; +}> = ; + +export default class UtilitiesDemo extends Component { + +} diff --git a/test-app/app/templates/core.hbs b/test-app/app/templates/core.hbs index 23e6f373..37d61c51 100644 --- a/test-app/app/templates/core.hbs +++ b/test-app/app/templates/core.hbs @@ -1,4 +1,4 @@ {{page-title "Core"}} {{outlet}} - \ No newline at end of file + \ No newline at end of file diff --git a/test-app/tests/integration/components/utilities/avatar-test.gts b/test-app/tests/integration/components/utilities/avatar-test.gts new file mode 100644 index 00000000..71283a45 --- /dev/null +++ b/test-app/tests/integration/components/utilities/avatar-test.gts @@ -0,0 +1,107 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import { render } from '@ember/test-helpers'; +import { Avatar } from '@frontile/utilities'; + +module( + 'Integration | Component | @frontile/utilities/Avatar', + function (hooks) { + setupRenderingTest(hooks); + + test('it renders initials from full name', async function (assert) { + await render( + + ); + + assert.dom('[data-test-avatar]').hasText('JS'); + }); + + test('it renders initials from first and last name', async function (assert) { + await render( + + ); + + assert.dom('[data-test-avatar]').hasText('JD'); + }); + + test('it renders initials when only first name is provided', async function (assert) { + await render( + + ); + + assert.dom('[data-test-avatar]').hasText('A'); + }); + + test('it renders initials when only last name is provided', async function (assert) { + await render( + + ); + + assert.dom('[data-test-avatar]').hasText('B'); + }); + + test('it prioritizes name over first and last name', async function (assert) { + await render( + + ); + + assert.dom('[data-test-avatar]').hasText('CC'); + }); + + test('it handles extra spaces in the full name', async function (assert) { + await render( + + ); + + assert.dom('[data-test-avatar]').hasText('AM'); + }); + + test('it shows only one initial if only one name is available', async function (assert) { + await render( + + ); + + assert.dom('[data-test-avatar]').hasText('M'); + }); + + test('it does not render initials if no name, first name, or last name is provided', async function (assert) { + await render(); + + assert.dom('[data-test-avatar]').hasText(''); + }); + + test('it renders image when src is provided', async function (assert) { + await render( + + ); + + assert.dom('[data-test-avatar] img').exists(); + assert.dom('[data-test-avatar] img').hasAttribute('src', '/avatar.jpg'); + assert.dom('[data-test-avatar] img').hasAttribute('alt', 'User Avatar'); + }); + + test('it does not render initials when an image is present', async function (assert) { + await render( + + ); + + assert.dom('[data-test-avatar] img').exists(); + assert.dom('[data-test-avatar]').doesNotContainText('JS'); + }); + } +);