From 26d0866010a8e214aacef5ba1b27791801e4d664 Mon Sep 17 00:00:00 2001 From: Chris Holt Date: Fri, 24 Feb 2023 17:09:43 -0800 Subject: [PATCH] (web-components): add avatar as a new Fluent 2 aligned component (#26729) * add avatar as a new component * add export path * update helpers path in avatar stories * create public static value for named colors * update styles to support any slotted badge * add size 16 styles and stories * update animations to use tokens * add support for prefers reduced motion on active and inactive transitions * change set to generate for color and initials fns * add large badge styles * remove unnecessary badge cchange handlers * fix brand color in dark mode with static inverted value * update change file * remove unnecessary lifecycle event * add avatar readme for react deltas * update attributes as optional * update api report --- ...-1c73e6e8-61a0-46c8-b69e-0820f5a2488d.json | 7 + packages/web-components/.eslintrc.json | 1 + packages/web-components/docs/api-report.md | 322 ++++++++++ packages/web-components/package.json | 4 + packages/web-components/src/avatar/README.md | 59 ++ .../src/avatar/avatar.definition.ts | 17 + .../src/avatar/avatar.options.ts | 125 ++++ .../src/avatar/avatar.stories.ts | 194 ++++++ .../src/avatar/avatar.styles.ts | 577 ++++++++++++++++++ .../src/avatar/avatar.template.ts | 31 + packages/web-components/src/avatar/avatar.ts | 158 +++++ packages/web-components/src/avatar/define.ts | 4 + packages/web-components/src/avatar/index.ts | 5 + packages/web-components/src/index.ts | 1 + .../web-components/src/utils/get-initials.ts | 110 ++++ 15 files changed, 1615 insertions(+) create mode 100644 change/@fluentui-web-components-1c73e6e8-61a0-46c8-b69e-0820f5a2488d.json create mode 100644 packages/web-components/src/avatar/README.md create mode 100644 packages/web-components/src/avatar/avatar.definition.ts create mode 100644 packages/web-components/src/avatar/avatar.options.ts create mode 100644 packages/web-components/src/avatar/avatar.stories.ts create mode 100644 packages/web-components/src/avatar/avatar.styles.ts create mode 100644 packages/web-components/src/avatar/avatar.template.ts create mode 100644 packages/web-components/src/avatar/avatar.ts create mode 100644 packages/web-components/src/avatar/define.ts create mode 100644 packages/web-components/src/avatar/index.ts create mode 100644 packages/web-components/src/utils/get-initials.ts diff --git a/change/@fluentui-web-components-1c73e6e8-61a0-46c8-b69e-0820f5a2488d.json b/change/@fluentui-web-components-1c73e6e8-61a0-46c8-b69e-0820f5a2488d.json new file mode 100644 index 00000000000000..ab3341afdf7adf --- /dev/null +++ b/change/@fluentui-web-components-1c73e6e8-61a0-46c8-b69e-0820f5a2488d.json @@ -0,0 +1,7 @@ +{ + "type": "prerelease", + "comment": "feat(avatar): add Avatar web component", + "packageName": "@fluentui/web-components", + "email": "chhol@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/packages/web-components/.eslintrc.json b/packages/web-components/.eslintrc.json index ca5b46cb7c91c3..29a022348e612f 100644 --- a/packages/web-components/.eslintrc.json +++ b/packages/web-components/.eslintrc.json @@ -18,6 +18,7 @@ "no-prototype-builtins": "off", "no-fallthrough": "off", "no-unexpected-multiline": "off", + "no-useless-escape": "off", "import/order": "error", "sort-imports": [ "error", diff --git a/packages/web-components/docs/api-report.md b/packages/web-components/docs/api-report.md index 4563c8b9e70f42..2e81845851dce4 100644 --- a/packages/web-components/docs/api-report.md +++ b/packages/web-components/docs/api-report.md @@ -5,18 +5,234 @@ ```ts import { CSSDesignToken } from '@microsoft/fast-foundation'; +import { DividerOrientation } from '@microsoft/fast-foundation'; +import { DividerRole } from '@microsoft/fast-foundation'; import { ElementStyles } from '@microsoft/fast-element'; import { ElementViewTemplate } from '@microsoft/fast-element'; +import { FASTAccordion } from '@microsoft/fast-foundation'; +import { FASTAccordionItem } from '@microsoft/fast-foundation'; +import { FASTDivider } from '@microsoft/fast-foundation'; import { FASTElement } from '@microsoft/fast-element'; import { FASTElementDefinition } from '@microsoft/fast-element'; import { FASTProgress } from '@microsoft/fast-foundation'; import { FASTProgressRing } from '@microsoft/fast-foundation'; +import { FASTSwitch } from '@microsoft/fast-foundation'; import { StartEnd } from '@microsoft/fast-foundation'; import { StartEndOptions } from '@microsoft/fast-foundation'; import { StaticallyComposableHTML } from '@microsoft/fast-foundation'; import type { Theme } from '@fluentui/tokens'; import { ValuesOf } from '@microsoft/fast-foundation'; +// @public +export class Accordion extends FASTAccordion { +} + +// @public +export const accordionDefinition: FASTElementDefinition; + +// Warning: (ae-internal-missing-underscore) The name "AccordionItem" should be prefixed with an underscore because the declaration is marked as @internal +// +// @internal (undocumented) +export class AccordionItem extends FASTAccordionItem { + // @public + block: boolean; + // @public + expandIconPosition: AccordionItemExpandIconPosition; + // @public + size: AccordionItemSize; +} + +// Warning: (ae-incompatible-release-tags) The symbol "definition" is marked as @public, but its signature references "AccordionItem" which is marked as @internal +// +// @public +export const accordionItemDefinition: FASTElementDefinition; + +// @public +export const AccordionItemExpandIconPosition: { + readonly start: "start"; + readonly end: "end"; +}; + +// @public +export type AccordionItemExpandIconPosition = ValuesOf; + +// @public +export const AccordionItemSize: { + readonly small: "small"; + readonly medium: "medium"; + readonly large: "large"; + readonly extraLarge: "extra-large"; +}; + +// @public +export type AccordionItemSize = ValuesOf; + +// @public (undocumented) +export const accordionItemStyles: ElementStyles; + +// Warning: (ae-incompatible-release-tags) The symbol "template" is marked as @public, but its signature references "AccordionItem" which is marked as @internal +// +// @public +export const accordionItemTemplate: ElementViewTemplate; + +// @public (undocumented) +export const accordionStyles: ElementStyles; + +// @public (undocumented) +export const accordionTemplate: ElementViewTemplate; + +// @public +export class Avatar extends FASTElement { + active?: AvatarActive | undefined; + appearance?: AvatarAppearance | undefined; + color?: AvatarColor; + colorId?: AvatarNamedColor | undefined; + static colors: ("anchor" | "dark-red" | "cranberry" | "red" | "pumpkin" | "peach" | "marigold" | "gold" | "brass" | "brown" | "forest" | "seafoam" | "dark-green" | "light-teal" | "teal" | "steel" | "blue" | "royal-blue" | "cornflower" | "navy" | "lavender" | "purple" | "grape" | "lilac" | "pink" | "magenta" | "plum" | "beige" | "mink" | "platinum")[]; + // @internal + generateColor(): AvatarColor | void; + // @internal + generateInitials(): string | void; + initials?: string | undefined; + name?: string | undefined; + shape?: AvatarShape | undefined; + size?: AvatarSize | undefined; +} + +// @public +export const AvatarActive: { + readonly active: "active"; + readonly inactive: "inactive"; +}; + +// @public +export type AvatarActive = ValuesOf; + +// @public +export const AvatarAppearance: { + readonly ring: "ring"; + readonly shadow: "shadow"; + readonly ringShadow: "ring-shadow"; +}; + +// @public +export type AvatarAppearance = ValuesOf; + +// @public +export const AvatarColor: { + readonly darkRed: "dark-red"; + readonly cranberry: "cranberry"; + readonly red: "red"; + readonly pumpkin: "pumpkin"; + readonly peach: "peach"; + readonly marigold: "marigold"; + readonly gold: "gold"; + readonly brass: "brass"; + readonly brown: "brown"; + readonly forest: "forest"; + readonly seafoam: "seafoam"; + readonly darkGreen: "dark-green"; + readonly lightTeal: "light-teal"; + readonly teal: "teal"; + readonly steel: "steel"; + readonly blue: "blue"; + readonly royalBlue: "royal-blue"; + readonly cornflower: "cornflower"; + readonly navy: "navy"; + readonly lavender: "lavender"; + readonly purple: "purple"; + readonly grape: "grape"; + readonly lilac: "lilac"; + readonly pink: "pink"; + readonly magenta: "magenta"; + readonly plum: "plum"; + readonly beige: "beige"; + readonly mink: "mink"; + readonly platinum: "platinum"; + readonly anchor: "anchor"; + readonly neutral: "neutral"; + readonly brand: "brand"; + readonly colorful: "colorful"; +}; + +// @public +export type AvatarColor = ValuesOf; + +// @public +export const AvatarDefinition: FASTElementDefinition; + +// @public +export const AvatarNamedColor: { + readonly darkRed: "dark-red"; + readonly cranberry: "cranberry"; + readonly red: "red"; + readonly pumpkin: "pumpkin"; + readonly peach: "peach"; + readonly marigold: "marigold"; + readonly gold: "gold"; + readonly brass: "brass"; + readonly brown: "brown"; + readonly forest: "forest"; + readonly seafoam: "seafoam"; + readonly darkGreen: "dark-green"; + readonly lightTeal: "light-teal"; + readonly teal: "teal"; + readonly steel: "steel"; + readonly blue: "blue"; + readonly royalBlue: "royal-blue"; + readonly cornflower: "cornflower"; + readonly navy: "navy"; + readonly lavender: "lavender"; + readonly purple: "purple"; + readonly grape: "grape"; + readonly lilac: "lilac"; + readonly pink: "pink"; + readonly magenta: "magenta"; + readonly plum: "plum"; + readonly beige: "beige"; + readonly mink: "mink"; + readonly platinum: "platinum"; + readonly anchor: "anchor"; +}; + +// @public +export type AvatarNamedColor = ValuesOf; + +// @public +export const AvatarShape: { + readonly circular: "circular"; + readonly square: "square"; +}; + +// @public +export type AvatarShape = ValuesOf; + +// @public +export const AvatarSize: { + readonly _16: 16; + readonly _20: 20; + readonly _24: 24; + readonly _28: 28; + readonly _32: 32; + readonly _36: 36; + readonly _40: 40; + readonly _48: 48; + readonly _56: 56; + readonly _64: 64; + readonly _72: 72; + readonly _96: 96; + readonly _120: 120; + readonly _128: 128; +}; + +// @public +export type AvatarSize = ValuesOf; + +// @public +export const AvatarStyles: ElementStyles; + +// @public (undocumented) +export const AvatarTemplate: ElementViewTemplate; + // Warning: (ae-internal-mixed-release-tag) Mixed release tags are not allowed for "Badge" because one of its declarations is marked as @internal // // @public @@ -1101,6 +1317,50 @@ export const curveEasyEaseMax: CSSDesignToken; // @public (undocumented) export const curveLinear: CSSDesignToken; +// @public +export const definition: FASTElementDefinition; + +// @public +export class Divider extends FASTDivider { + alignContent?: DividerAlignContent; + appearance?: DividerAppearance; + inset?: boolean; +} + +// @public +export const DividerAlignContent: { + readonly center: "center"; + readonly start: "start"; + readonly end: "end"; +}; + +// @public +export type DividerAlignContent = ValuesOf; + +// @public +export const DividerAppearance: { + readonly strong: "strong"; + readonly brand: "brand"; + readonly subtle: "subtle"; + readonly default: "default"; +}; + +// @public +export type DividerAppearance = ValuesOf; + +// @public +export const DividerDefinition: FASTElementDefinition; + +export { DividerOrientation } + +export { DividerRole } + +// @public +export const DividerStyles: ElementStyles; + +// @public +export const DividerTemplate: ElementViewTemplate; + // @public (undocumented) export const durationFast: CSSDesignToken; @@ -1173,6 +1433,47 @@ export const fontWeightRegular: CSSDesignToken; // @public (undocumented) export const fontWeightSemibold: CSSDesignToken; +// @public +class Image_2 extends FASTElement { + block?: boolean; + bordered?: boolean; + fit?: ImageFit; + shadow?: boolean; + shape?: ImageShape; +} +export { Image_2 as Image } + +// @public +export const ImageDefinition: FASTElementDefinition; + +// @public +export const ImageFit: { + readonly none: "none"; + readonly center: "center"; + readonly contain: "contain"; + readonly cover: "cover"; + readonly default: "default"; +}; + +// @public +export type ImageFit = ValuesOf; + +// @public +export const ImageShape: { + readonly circular: "circular"; + readonly rounded: "rounded"; + readonly square: "square"; +}; + +// @public (undocumented) +export type ImageShape = ValuesOf; + +// @public +export const ImageStyles: ElementStyles; + +// @public +export const ImageTemplate: ElementViewTemplate; + // @public (undocumented) export const lineHeightBase100: CSSDesignToken; @@ -1403,6 +1704,27 @@ export const strokeWidthThickest: CSSDesignToken; // @public (undocumented) export const strokeWidthThin: CSSDesignToken; +// @public (undocumented) +export class Switch extends FASTSwitch { + labelPosition: SwitchLabelPosition | undefined; +} + +// @public +export const SwitchLabelPosition: { + readonly above: "above"; + readonly after: "after"; + readonly before: "before"; +}; + +// @public +export type SwitchLabelPosition = ValuesOf; + +// @public (undocumented) +export const switchStyles: ElementStyles; + +// @public (undocumented) +export const switchTemplate: ElementViewTemplate; + // @public class Text_2 extends FASTElement { align: TextAlign; diff --git a/packages/web-components/package.json b/packages/web-components/package.json index 14de3ce922e06e..7093c1856539d6 100644 --- a/packages/web-components/package.json +++ b/packages/web-components/package.json @@ -32,6 +32,10 @@ "types": "./dist/esm/accordion-item/define.d.ts", "default": "./dist/esm/accordion-item/define.js" }, + "./avatar": { + "types": "./dist/esm/avatar/define.d.ts", + "default": "./dist/esm/avatar/define.js" + }, "./badge": { "types": "./dist/esm/badge/define.d.ts", "default": "./dist/esm/badge/define.js" diff --git a/packages/web-components/src/avatar/README.md b/packages/web-components/src/avatar/README.md new file mode 100644 index 00000000000000..0aea250cdf8db3 --- /dev/null +++ b/packages/web-components/src/avatar/README.md @@ -0,0 +1,59 @@ +# Avatar + +The Avatar component represents a person or entity. It displays the person's image, initials, or an icon, and can be either circular or square. + +## **Design Spec** + +[Link to Avatar in Figma](https://www.figma.com/file/3SlxyaJA3tpLs5rVZ4oTVj/Avatar?node-id=0%3A1&t=Ugsg41JLdURbxd7i-1) + +
+ +## **Engineering Spec** + +Fluent WC3 Avatar has feature parity with the Fluent UI React 9 Accordion implementation but not direct parity. + +
+ +## Class: `Avatar` + +
+ +### **Component Name** + +`` + +
+ +## **Preparation** + +
+ +### **Fluent Web Component v3 v.s Fluent React 9** + +
+ +**Component and Slot Mapping** + +| Fluent UI React 9 | Fluent Web Components 3 | +| ----------------- | ----------------------- | +| `` | `` | + +
+ +**Property Mapping** +| Fluent UI React 9 | Fluent Web Components 3 | Description of difference | +| ------------------------- | ---------------------------- | ---------------------------------------------------------------------------------------- | +| `idForColor: string`| `colorId: string` | both are strings, the delta here is primarily verbosity. In web components attributes need to follow HTML syntax for attributes. The property of `colorId` maps to `color-id`. Were we to map directly we would have an attribute of `id-for-color` which seems overly verbose. Almost all HTML attributes are at max hyphenated once. This proposes an attribute which is less verbose and only requires a single `-`. | +| `size` | `size` | | +| `shape` | `shape` | +| `active` | `active` | The only delta here is that the web components are aligning to the resolved RFC to use `undefined` for fields which are intended to "unset" attributes | +| `activeAppearance` | `appearance` | The delta here is semantic only, unless we need to reserve the appearance namespace, brevity seems preferred here | +| `name` | `name` | +| `initials` | `initials` | + +**Additional Deltas:** + +The FUIR9 implementation seems to utilize several "slots", whereas with the web component implementation includes two primary slots. + +1. Default slot - When a name or initials is provided, the default slot projects the initials generated via name or initials. If an image is slotted into the default slot, the image will be shown. If an SVG is slotted into the default slot an SVG will project overriding any other value. If name and initials are not provided and nothing is slotted, the default avatar svg is projected. +2. Badge - The slot for the badge diff --git a/packages/web-components/src/avatar/avatar.definition.ts b/packages/web-components/src/avatar/avatar.definition.ts new file mode 100644 index 00000000000000..6f076756e58606 --- /dev/null +++ b/packages/web-components/src/avatar/avatar.definition.ts @@ -0,0 +1,17 @@ +import { FluentDesignSystem } from '../fluent-design-system.js'; +import { Avatar } from './avatar.js'; +import { styles } from './avatar.styles.js'; +import { template } from './avatar.template.js'; + +/** + * The Fluent Avatar Element. + * + * @public + * @remarks + * HTML Element: \ + */ +export const definition = Avatar.compose({ + name: `${FluentDesignSystem.prefix}-avatar`, + template, + styles, +}); diff --git a/packages/web-components/src/avatar/avatar.options.ts b/packages/web-components/src/avatar/avatar.options.ts new file mode 100644 index 00000000000000..8d6db2d8c7a407 --- /dev/null +++ b/packages/web-components/src/avatar/avatar.options.ts @@ -0,0 +1,125 @@ +import { ValuesOf } from '@microsoft/fast-foundation'; + +/** + * The Avatar "active" state + */ +export const AvatarActive = { + active: 'active', + inactive: 'inactive', +} as const; + +/** + * The types of Avatar active state + */ +export type AvatarActive = ValuesOf; + +/** + * The Avatar Shape + */ +export const AvatarShape = { + circular: 'circular', + square: 'square', +} as const; + +/** + * The types of Avatar Shape + */ +export type AvatarShape = ValuesOf; + +/** + * The Avatar Appearance when "active" + */ +export const AvatarAppearance = { + ring: 'ring', + shadow: 'shadow', + ringShadow: 'ring-shadow', +} as const; + +/** + * The appearance when "active" + */ +export type AvatarAppearance = ValuesOf; + +/** + * A specific named color for the Avatar + */ +export const AvatarNamedColor = { + darkRed: 'dark-red', + cranberry: 'cranberry', + red: 'red', + pumpkin: 'pumpkin', + peach: 'peach', + marigold: 'marigold', + gold: 'gold', + brass: 'brass', + brown: 'brown', + forest: 'forest', + seafoam: 'seafoam', + darkGreen: 'dark-green', + lightTeal: 'light-teal', + teal: 'teal', + steel: 'steel', + blue: 'blue', + royalBlue: 'royal-blue', + cornflower: 'cornflower', + navy: 'navy', + lavender: 'lavender', + purple: 'purple', + grape: 'grape', + lilac: 'lilac', + pink: 'pink', + magenta: 'magenta', + plum: 'plum', + beige: 'beige', + mink: 'mink', + platinum: 'platinum', + anchor: 'anchor', +} as const; + +/** + * An avatar can be one of named colors + * @public + */ +export type AvatarNamedColor = ValuesOf; + +/** + * Supported Avatar colors + */ +export const AvatarColor = { + neutral: 'neutral', + brand: 'brand', + colorful: 'colorful', + ...AvatarNamedColor, +} as const; + +/** + * The Avatar Color + */ +export type AvatarColor = ValuesOf; + +/** + * The Avatar Sizes + * @public + */ +export const AvatarSize = { + _16: 16, + _20: 20, + _24: 24, + _28: 28, + _32: 32, + _36: 36, + _40: 40, + _48: 48, + _56: 56, + _64: 64, + _72: 72, + _96: 96, + _120: 120, + _128: 128, +} as const; + +/** + * A Avatar can be on of several preset sizes. + * @public + */ +export type AvatarSize = ValuesOf; diff --git a/packages/web-components/src/avatar/avatar.stories.ts b/packages/web-components/src/avatar/avatar.stories.ts new file mode 100644 index 00000000000000..8765138662cca3 --- /dev/null +++ b/packages/web-components/src/avatar/avatar.stories.ts @@ -0,0 +1,194 @@ +import { html } from '@microsoft/fast-element'; +import type { Args, Meta } from '@storybook/html'; +import { renderComponent } from '../helpers.stories.js'; +import type { Avatar as FluentAvatar } from './avatar.js'; +import { AvatarActive, AvatarAppearance, AvatarColor, AvatarShape, AvatarSize } from './avatar.options.js'; +import './define.js'; + +type AvatarStoryArgs = Args & FluentAvatar; +type AvatarStoryMeta = Meta; + +const storyTemplate = html` + +`; + +export default { + title: 'Components/Avatar', + argTypes: { + active: { + options: Object.values(AvatarActive), + control: { + type: 'select', + }, + }, + appearance: { + options: Object.values(AvatarAppearance), + control: { + type: 'select', + }, + }, + color: { + options: Object.values(AvatarColor), + control: { + type: 'select', + }, + }, + initials: { + control: 'text', + }, + name: { + control: 'text', + }, + shape: { + options: Object.values(AvatarShape), + control: { + type: 'select', + }, + }, + size: { + options: Object.values(AvatarSize), + control: { + type: 'select', + }, + }, + }, +} as AvatarStoryMeta; + +export const Avatar = renderComponent(storyTemplate).bind({}); + +export const Image = renderComponent(html` + +`); + +export const Icon = renderComponent(html` + +`); + +export const Badge = renderComponent(html` `); + +export const ColorBrand = renderComponent(html``); + +export const Color = renderComponent(html` +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+`); + +export const Colorful = renderComponent(html` +
+ + + + + + + + + + + + +
+`); + +export const Shape = renderComponent(html` + + +`); + +export const Active = renderComponent(html` +
+ U + A + I +
+
+`); + +export const ActiveAppearance = renderComponent(html` +
+ R + S + RS +
+`); + +export const CustomInitials = renderComponent(html` `); + +export const Size = renderComponent(html` +
+ 16 + 20 + 24 + 28 + 32 + 36 + 40 + 48 + 56 + 64 + 72 + 96 + 120 + 128 +
+`); diff --git a/packages/web-components/src/avatar/avatar.styles.ts b/packages/web-components/src/avatar/avatar.styles.ts new file mode 100644 index 00000000000000..50604d331c7116 --- /dev/null +++ b/packages/web-components/src/avatar/avatar.styles.ts @@ -0,0 +1,577 @@ +import { css } from '@microsoft/fast-element'; +import { display } from '@microsoft/fast-foundation'; +import { + borderRadiusCircular, + borderRadiusLarge, + borderRadiusMedium, + borderRadiusSmall, + borderRadiusXLarge, + colorBrandBackgroundStatic, + colorNeutralBackground1, + colorNeutralBackground6, + colorNeutralForeground3, + colorNeutralForegroundStaticInverted, + colorPaletteAnchorBackground2, + colorPaletteAnchorForeground2, + colorPaletteBeigeBackground2, + colorPaletteBeigeForeground2, + colorPaletteBlueBackground2, + colorPaletteBlueForeground2, + colorPaletteBrassBackground2, + colorPaletteBrassForeground2, + colorPaletteBrownBackground2, + colorPaletteBrownForeground2, + colorPaletteCornflowerBackground2, + colorPaletteCornflowerForeground2, + colorPaletteCranberryBackground2, + colorPaletteCranberryForeground2, + colorPaletteDarkGreenBackground2, + colorPaletteDarkGreenForeground2, + colorPaletteDarkRedBackground2, + colorPaletteDarkRedForeground2, + colorPaletteForestBackground2, + colorPaletteForestForeground2, + colorPaletteGoldBackground2, + colorPaletteGoldForeground2, + colorPaletteGrapeBackground2, + colorPaletteGrapeForeground2, + colorPaletteLavenderBackground2, + colorPaletteLavenderForeground2, + colorPaletteLightTealBackground2, + colorPaletteLightTealForeground2, + colorPaletteLilacBackground2, + colorPaletteLilacForeground2, + colorPaletteMagentaBackground2, + colorPaletteMagentaForeground2, + colorPaletteMarigoldBackground2, + colorPaletteMarigoldForeground2, + colorPaletteMinkBackground2, + colorPaletteMinkForeground2, + colorPaletteNavyBackground2, + colorPaletteNavyForeground2, + colorPalettePeachBackground2, + colorPalettePeachForeground2, + colorPalettePinkBackground2, + colorPalettePinkForeground2, + colorPalettePlatinumBackground2, + colorPalettePlatinumForeground2, + colorPalettePlumBackground2, + colorPalettePlumForeground2, + colorPalettePumpkinBackground2, + colorPalettePumpkinForeground2, + colorPalettePurpleBackground2, + colorPalettePurpleForeground2, + colorPaletteRedBackground2, + colorPaletteRedForeground2, + colorPaletteRoyalBlueBackground2, + colorPaletteRoyalBlueForeground2, + colorPaletteSeafoamBackground2, + colorPaletteSeafoamForeground2, + colorPaletteSteelBackground2, + colorPaletteSteelForeground2, + colorPaletteTealBackground2, + colorPaletteTealForeground2, + curveAccelerateMax, + curveAccelerateMid, + curveAccelerateMin, + curveDecelerateMax, + curveDecelerateMid, + curveDecelerateMin, + curveEasyEase, + curveEasyEaseMax, + curveLinear, + durationFaster, + durationSlower, + durationUltraSlow, + fontFamilyBase, + fontSizeBase100, + fontSizeBase200, + fontSizeBase300, + fontSizeBase400, + fontSizeBase500, + fontSizeBase600, + fontWeightRegular, + fontWeightSemibold, + shadow16, + shadow28, + shadow4, + shadow8, + strokeWidthThick, + strokeWidthThicker, + strokeWidthThickest, + strokeWidthThin, +} from '../theme/design-tokens.js'; + +const animations = { + fastOutSlowInMax: curveDecelerateMax, + fastOutSlowInMid: curveDecelerateMid, + fastOutSlowInMin: curveDecelerateMin, + slowOutFastInMax: curveAccelerateMax, + slowOutFastInMid: curveAccelerateMid, + slowOutFastInMin: curveAccelerateMin, + fastEase: curveEasyEaseMax, + normalEase: curveEasyEase, + nullEasing: curveLinear, +}; + +/** Avatar styles + * @public + */ +export const styles = css` + ${display('inline-flex')} :host { + position: relative; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 32px; + height: 32px; + font-family: ${fontFamilyBase}; + font-weight: ${fontWeightSemibold}; + font-size: ${fontSizeBase300}; + border-radius: ${borderRadiusCircular}; + color: ${colorNeutralForeground3}; + background-color: ${colorNeutralBackground6}; + } + + .default-icon, + ::slotted(svg) { + width: 20px; + height: 20px; + font-size: 20px; + } + + ::slotted(img) { + box-sizing: border-box; + width: 100%; + height: 100%; + border-radius: ${borderRadiusCircular}; + } + + ::slotted([slot='badge']) { + position: absolute; + bottom: 0; + right: 0; + box-shadow: 0 0 0 ${strokeWidthThin} ${colorNeutralBackground1}; + } + + :host([size='64']) ::slotted([slot='badge']), + :host([size='72']) ::slotted([slot='badge']), + :host([size='96']) ::slotted([slot='badge']), + :host([size='120']) ::slotted([slot='badge']), + :host([size='128']) ::slotted([slot='badge']) { + box-shadow: 0 0 0 ${strokeWidthThick} ${colorNeutralBackground1}; + } + + :host([size='16']), + :host([size='20']), + :host([size='24']) { + font-size: ${fontSizeBase100}; + font-weight: ${fontWeightRegular}; + } + + :host([size='16']) { + width: 16px; + height: 16px; + } + + :host([size='20']) { + width: 20px; + height: 20px; + } + + :host([size='24']) { + width: 24px; + height: 24px; + } + + :host([size='16']) .default-icon, + :host([size='16']) ::slotted(svg) { + width: 12px; + height: 12px; + font-size: 12px; + } + + :host([size='20']) .default-icon, + :host([size='24']) .default-icon, + :host([size='20']) ::slotted(svg), + :host([size='24']) ::slotted(svg) { + width: 16px; + height: 16px; + font-size: 16px; + } + + :host([size='28']) { + width: 28px; + height: 28px; + font-size: ${fontSizeBase200}; + } + + :host([size='36']) { + width: 36px; + height: 36px; + } + + :host([size='40']) { + width: 40px; + height: 40px; + } + + :host([size='48']), + :host([size='56']) { + font-size: ${fontSizeBase400}; + } + + :host([size='48']) { + width: 48px; + height: 48px; + } + + :host([size='48']) .default-icon, + :host([size='48']) ::slotted(svg) { + width: 24px; + height: 24px; + font-size: 24px; + } + + :host([size='56']) { + width: 56px; + height: 56px; + } + + :host([size='56']) .default-icon, + :host([size='56']) ::slotted(svg) { + width: 28px; + height: 28px; + font-size: 28px; + } + + :host([size='64']), + :host([size='72']), + :host([size='96']) { + font-size: ${fontSizeBase500}; + } + + :host([size='64']) .default-icon, + :host([size='72']) .default-icon, + :host([size='64']) ::slotted(svg), + :host([size='72']) ::slotted(svg) { + width: 32px; + height: 32px; + font-size: 32px; + } + + :host([size='64']) { + width: 64px; + height: 64px; + } + + :host([size='72']) { + width: 72px; + height: 72px; + } + + :host([size='96']) { + width: 96px; + height: 96px; + } + + :host([size='96']) .default-icon, + :host([size='120']) .default-icon, + :host([size='128']) .default-icon, + :host([size='96']) ::slotted(svg), + :host([size='120']) ::slotted(svg), + :host([size='128']) ::slotted(svg) { + width: 48px; + height: 48px; + font-size: 48px; + } + + :host([size='120']), + :host([size='128']) { + font-size: ${fontSizeBase600}; + } + + :host([size='120']) { + width: 120px; + height: 120px; + } + + :host([size='128']) { + width: 128px; + height: 128px; + } + + :host([shape='square']) { + border-radius: ${borderRadiusMedium}; + } + + :host([shape='square'][size='20']), + :host([shape='square'][size='24']) { + border-radius: ${borderRadiusSmall}; + } + + :host([shape='square'][size='56']), + :host([shape='square'][size='64']), + :host([shape='square'][size='72']) { + border-radius: ${borderRadiusLarge}; + } + :host([shape='square'][size='96']), + :host([shape='square'][size='120']), + :host([shape='square'][size='128']) { + border-radius: ${borderRadiusXLarge}; + } + + :host([data-color='brand']) { + color: ${colorNeutralForegroundStaticInverted}; + background-color: ${colorBrandBackgroundStatic}; + } + + :host([data-color='dark-red']) { + color: ${colorPaletteDarkRedForeground2}; + background-color: ${colorPaletteDarkRedBackground2}; + } + + :host([data-color='cranberry']) { + color: ${colorPaletteCranberryForeground2}; + background-color: ${colorPaletteCranberryBackground2}; + } + + :host([data-color='red']) { + color: ${colorPaletteRedForeground2}; + background-color: ${colorPaletteRedBackground2}; + } + + :host([data-color='pumpkin']) { + color: ${colorPalettePumpkinForeground2}; + background-color: ${colorPalettePumpkinBackground2}; + } + + :host([data-color='peach']) { + color: ${colorPalettePeachForeground2}; + background-color: ${colorPalettePeachBackground2}; + } + + :host([data-color='marigold']) { + color: ${colorPaletteMarigoldForeground2}; + background-color: ${colorPaletteMarigoldBackground2}; + } + + :host([data-color='gold']) { + color: ${colorPaletteGoldForeground2}; + background-color: ${colorPaletteGoldBackground2}; + } + + :host([data-color='brass']) { + color: ${colorPaletteBrassForeground2}; + background-color: ${colorPaletteBrassBackground2}; + } + + :host([data-color='brown']) { + color: ${colorPaletteBrownForeground2}; + background-color: ${colorPaletteBrownBackground2}; + } + + :host([data-color='forest']) { + color: ${colorPaletteForestForeground2}; + background-color: ${colorPaletteForestBackground2}; + } + + :host([data-color='seafoam']) { + color: ${colorPaletteSeafoamForeground2}; + background-color: ${colorPaletteSeafoamBackground2}; + } + + :host([data-color='dark-green']) { + color: ${colorPaletteDarkGreenForeground2}; + background-color: ${colorPaletteDarkGreenBackground2}; + } + + :host([data-color='light-teal']) { + color: ${colorPaletteLightTealForeground2}; + background-color: ${colorPaletteLightTealBackground2}; + } + + :host([data-color='teal']) { + color: ${colorPaletteTealForeground2}; + background-color: ${colorPaletteTealBackground2}; + } + + :host([data-color='steel']) { + color: ${colorPaletteSteelForeground2}; + background-color: ${colorPaletteSteelBackground2}; + } + + :host([data-color='blue']) { + color: ${colorPaletteBlueForeground2}; + background-color: ${colorPaletteBlueBackground2}; + } + + :host([data-color='royal-blue']) { + color: ${colorPaletteRoyalBlueForeground2}; + background-color: ${colorPaletteRoyalBlueBackground2}; + } + + :host([data-color='cornflower']) { + color: ${colorPaletteCornflowerForeground2}; + background-color: ${colorPaletteCornflowerBackground2}; + } + + :host([data-color='navy']) { + color: ${colorPaletteNavyForeground2}; + background-color: ${colorPaletteNavyBackground2}; + } + + :host([data-color='lavender']) { + color: ${colorPaletteLavenderForeground2}; + background-color: ${colorPaletteLavenderBackground2}; + } + + :host([data-color='purple']) { + color: ${colorPalettePurpleForeground2}; + background-color: ${colorPalettePurpleBackground2}; + } + + :host([data-color='grape']) { + color: ${colorPaletteGrapeForeground2}; + background-color: ${colorPaletteGrapeBackground2}; + } + + :host([data-color='lilac']) { + color: ${colorPaletteLilacForeground2}; + background-color: ${colorPaletteLilacBackground2}; + } + + :host([data-color='pink']) { + color: ${colorPalettePinkForeground2}; + background-color: ${colorPalettePinkBackground2}; + } + + :host([data-color='magenta']) { + color: ${colorPaletteMagentaForeground2}; + background-color: ${colorPaletteMagentaBackground2}; + } + + :host([data-color='plum']) { + color: ${colorPalettePlumForeground2}; + background-color: ${colorPalettePlumBackground2}; + } + + :host([data-color='beige']) { + color: ${colorPaletteBeigeForeground2}; + background-color: ${colorPaletteBeigeBackground2}; + } + + :host([data-color='mink']) { + color: ${colorPaletteMinkForeground2}; + background-color: ${colorPaletteMinkBackground2}; + } + + :host([data-color='platinum']) { + color: ${colorPalettePlatinumForeground2}; + background-color: ${colorPalettePlatinumBackground2}; + } + + :host([data-color='anchor']) { + color: ${colorPaletteAnchorForeground2}; + background-color: ${colorPaletteAnchorBackground2}; + } + + :host([active]) { + /* Work-around for text pixel snapping at the end of the animation */ + transform: perspective(1px); + transition-property: transform, opacity; + transition-duration: ${durationUltraSlow}, ${durationFaster}; + transition-delay: ${animations.fastEase}, ${animations.nullEasing}; + } + + :host([active])::before { + content: ''; + position: absolute; + top: 0; + left: 0; + bottom: 0; + right: 0; + border-radius: inherit; + transition-property: margin, opacity; + transition-duration: ${durationUltraSlow}, ${durationSlower}; + transition-delay: ${animations.fastEase}, ${animations.nullEasing}; + } + :host([active])::before { + box-shadow: ${shadow8}; + border-style: solid; + border-color: ${colorBrandBackgroundStatic}; + } + + :host([active][appearance='shadow'])::before { + border-style: none; + border-color: none; + } + + :host([active]:not([appearance='shadow']))::before { + margin: calc(-2 * ${strokeWidthThick}); + border-width: ${strokeWidthThick}; + } + + :host([size='56'][active]:not([appearance='shadow']))::before, + :host([size='64'][active]:not([appearance='shadow']))::before { + margin: calc(-2 * ${strokeWidthThicker}); + border-width: ${strokeWidthThicker}; + } + + :host([size='72'][active]:not([appearance='shadow']))::before, + :host([size='96'][active]:not([appearance='shadow']))::before, + :host([size='120'][active]:not([appearance='shadow']))::before, + :host([size='128'][active]:not([appearance='shadow']))::before { + margin: calc(-2 * ${strokeWidthThickest}); + border-width: ${strokeWidthThickest}; + } + + :host([size='20'][active][appearance])::before, + :host([size='24'][active][appearance])::before, + :host([size='28'][active][appearance])::before { + box-shadow: ${shadow4}; + } + + :host([size='56'][active][appearance])::before, + :host([size='64'][active][appearance])::before { + box-shadow: ${shadow16}; + } + + :host([size='72'][active][appearance])::before, + :host([size='96'][active][appearance])::before, + :host([size='120'][active][appearance])::before, + :host([size='128'][active][appearance])::before { + box-shadow: ${shadow28}; + } + + :host([active][appearance='ring'])::before { + box-shadow: none; + } + + :host([active='inactive']) { + opacity: 0.8; + transform: scale(0.875); + transition-property: transform, opacity; + transition-duration: ${durationUltraSlow}, ${durationFaster}; + transition-delay: ${animations.fastOutSlowInMin}, ${animations.nullEasing}; + } + + :host([active='inactive'])::before { + margin: 0; + opacity: 0; + transition-property: margin, opacity; + transition-duration: ${durationUltraSlow}, ${durationSlower}; + transition-delay: ${animations.fastOutSlowInMin}, ${animations.nullEasing}; + } + + @media screen and (prefers-reduced-motion: reduce) { + :host([active]) { + transition-duration: 0.01ms; + } + + :host([active])::before { + transition-duration: 0.01ms; + transition-delay: 0.01ms; + } + } +`; diff --git a/packages/web-components/src/avatar/avatar.template.ts b/packages/web-components/src/avatar/avatar.template.ts new file mode 100644 index 00000000000000..3e9f595d714052 --- /dev/null +++ b/packages/web-components/src/avatar/avatar.template.ts @@ -0,0 +1,31 @@ +import { ElementViewTemplate, html } from '@microsoft/fast-element'; +import type { Avatar } from './avatar.js'; + +const defaultIconTemplate = html``; + +/** + * The template for the Avatar component. + * @public + */ +export function avatarTemplate(): ElementViewTemplate { + return html` + + `; +} + +export const template: ElementViewTemplate = avatarTemplate(); diff --git a/packages/web-components/src/avatar/avatar.ts b/packages/web-components/src/avatar/avatar.ts new file mode 100644 index 00000000000000..da94ce2626f6e0 --- /dev/null +++ b/packages/web-components/src/avatar/avatar.ts @@ -0,0 +1,158 @@ +import { attr, FASTElement, nullableNumberConverter } from '@microsoft/fast-element'; +import { getInitials } from '../utils/get-initials.js'; +import { + AvatarActive, + AvatarAppearance, + AvatarColor, + AvatarNamedColor, + AvatarShape, + AvatarSize, +} from './avatar.options.js'; + +/** + * The base class used for constructing a fluent-avatar custom element + * @public + */ +export class Avatar extends FASTElement { + /** + * The name of the person or entity represented by this Avatar. This should always be provided if it is available. + * + * @public + * @remarks + * HTML Attribute: name + */ + @attr + public name?: string | undefined; + + /** + * Provide custom initials rather than one generated via the name + * + * @public + * @remarks + * HTML Attribute: name + */ + @attr + public initials?: string | undefined; + + /** + * Size of the avatar in pixels. + * + * Size is restricted to a limited set of supported values recommended for most uses (see `AvatarSizeValue`) and + * based on design guidelines for the Avatar control. + * + * If a non-supported size is neeeded, set `size` to the next-smaller supported size, and set `width` and `height` + * to override the rendered size. + * + * @public + * @remarks + * HTML Attribute: size + * + */ + @attr({ converter: nullableNumberConverter }) + public size?: AvatarSize | undefined; + + /** + * The avatar can have a circular or square shape. + * + * @public + * @remarks + * HTML Attribute: shape + */ + @attr + public shape?: AvatarShape | undefined; + + /** + * Optional activity indicator + * * active: the avatar will be decorated according to activeAppearance + * * inactive: the avatar will be reduced in size and partially transparent + * * undefined: normal display + * + * @public + * @remarks + * HTML Attribute: active + */ + @attr + public active?: AvatarActive | undefined; + + /** + * The appearance when `active="active"` + * + * @public + * @remarks + * HTML Attribute: appearance + */ + @attr + public appearance?: AvatarAppearance | undefined; + + /** + * The color when displaying either an icon or initials. + * * neutral (default): gray + * * brand: color from the brand palette + * * colorful: picks a color from a set of pre-defined colors, based on a hash of the name (or colorId if provided) + * * [AvatarNamedColor]: a specific color from the theme + * + * @public + * @remarks + * HTML Attribute: color + */ + @attr + public color?: AvatarColor = 'neutral'; + + /** + * Specify a string to be used instead of the name, to determine which color to use when color="colorful". + * Use this when a name is not available, but there is another unique identifier that can be used instead. + */ + @attr({ attribute: 'color-id' }) + public colorId?: AvatarNamedColor | undefined; + + /** + * Sets the data-color attribute used for the visual presentation + * @internal + */ + public generateColor(): AvatarColor | void { + if (!this.color) { + return; + } + + return this.color === AvatarColor.colorful + ? (Avatar.colors[getHashCode(this.colorId ?? this.name ?? '') % Avatar.colors.length] as AvatarColor) + : this.color; + } + + /** + * Generates and sets the initials for the template + * @internal + */ + public generateInitials(): string | void { + if (!this.name && !this.initials) { + return; + } + + // size can be undefined since we default it in CSS only + const size = this.size ?? 32; + + return ( + this.initials ?? + getInitials(this.name, window.getComputedStyle((this as unknown) as HTMLElement).direction === 'rtl', { + firstInitialOnly: size <= 16, + }) + ); + } + + /** + * An array of the available Avatar named colors + */ + public static colors = Object.values(AvatarNamedColor); +} + +// copied from React avatar +const getHashCode = (str: string): number => { + let hashCode = 0; + for (let len: number = str.length - 1; len >= 0; len--) { + const ch = str.charCodeAt(len); + const shift = len % 8; + hashCode ^= (ch << shift) + (ch >> (8 - shift)); // eslint-disable-line no-bitwise + } + + return hashCode; +}; diff --git a/packages/web-components/src/avatar/define.ts b/packages/web-components/src/avatar/define.ts new file mode 100644 index 00000000000000..34933837173dca --- /dev/null +++ b/packages/web-components/src/avatar/define.ts @@ -0,0 +1,4 @@ +import { FluentDesignSystem } from '../fluent-design-system.js'; +import { definition } from './avatar.definition.js'; + +definition.define(FluentDesignSystem.registry); diff --git a/packages/web-components/src/avatar/index.ts b/packages/web-components/src/avatar/index.ts new file mode 100644 index 00000000000000..8bb35aee4dc2a3 --- /dev/null +++ b/packages/web-components/src/avatar/index.ts @@ -0,0 +1,5 @@ +export * from './avatar.js'; +export * from './avatar.options.js'; +export { template as AvatarTemplate } from './avatar.template.js'; +export { styles as AvatarStyles } from './avatar.styles.js'; +export { definition as AvatarDefinition } from './avatar.definition.js'; diff --git a/packages/web-components/src/index.ts b/packages/web-components/src/index.ts index f8394d0a1cf521..041cf1b6983643 100644 --- a/packages/web-components/src/index.ts +++ b/packages/web-components/src/index.ts @@ -1,5 +1,6 @@ export * from './accordion/index.js'; export * from './accordion-item/index.js'; +export * from './avatar/index.js'; export * from './badge/index.js'; export * from './counter-badge/index.js'; export * from './divider/index.js'; diff --git a/packages/web-components/src/utils/get-initials.ts b/packages/web-components/src/utils/get-initials.ts new file mode 100644 index 00000000000000..7de388f103ec9b --- /dev/null +++ b/packages/web-components/src/utils/get-initials.ts @@ -0,0 +1,110 @@ +/* TODO: This file is a direct copy of the React Avatar utils */ + +/** + * Regular expressions matching characters to ignore when calculating the initials. + */ + +/** + * Regular expression matching characters within various types of enclosures, including the enclosures themselves + * so for example, (xyz) [xyz] {xyz} all would be ignored + */ +const UNWANTED_ENCLOSURES_REGEX: RegExp = /[\(\[\{][^\)\]\}]*[\)\]\}]/g; + +/** + * Regular expression matching special ASCII characters except space, plus some unicode special characters. + * Applies after unwanted enclosures have been removed + */ +// eslint-disable-next-line no-control-regex +const UNWANTED_CHARS_REGEX: RegExp = /[\0-\u001F\!-/:-@\[-`\{-\u00BF\u0250-\u036F\uD800-\uFFFF]/g; + +/** + * Regular expression matching phone numbers. Applied after chars matching UNWANTED_CHARS_REGEX have been removed + * and number has been trimmed for whitespaces + */ +const PHONENUMBER_REGEX: RegExp = /^\d+[\d\s]*(:?ext|x|)\s*\d+$/i; + +/** Regular expression matching one or more spaces. */ +const MULTIPLE_WHITESPACES_REGEX: RegExp = /\s+/g; + +/** + * Regular expression matching languages for which we currently don't support initials. + * Arabic: Arabic, Arabic Supplement, Arabic Extended-A. + * Korean: Hangul Jamo, Hangul Compatibility Jamo, Hangul Jamo Extended-A, Hangul Syllables, Hangul Jamo Extended-B. + * Japanese: Hiragana, Katakana. + * CJK: CJK Unified Ideographs Extension A, CJK Unified Ideographs, CJK Compatibility Ideographs, + * CJK Unified Ideographs Extension B + */ +// eslint-disable-next-line +const UNSUPPORTED_TEXT_REGEX: RegExp = /[\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\u1100-\u11FF\u3130-\u318F\uA960-\uA97F\uAC00-\uD7AF\uD7B0-\uD7FF\u3040-\u309F\u30A0-\u30FF\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF]|[\uD840-\uD869][\uDC00-\uDED6]/; + +function getInitialsLatin(displayName: string, isRtl: boolean, firstInitialOnly?: boolean): string { + let initials = ''; + + const splits: string[] = displayName.split(' '); + if (splits.length !== 0) { + initials += splits[0].charAt(0).toUpperCase(); + } + + if (!firstInitialOnly) { + if (splits.length === 2) { + initials += splits[1].charAt(0).toUpperCase(); + } else if (splits.length === 3) { + initials += splits[2].charAt(0).toUpperCase(); + } + } + + if (isRtl && initials.length > 1) { + return initials.charAt(1) + initials.charAt(0); + } + + return initials; +} + +function cleanupDisplayName(displayName: string): string { + displayName = displayName.replace(UNWANTED_ENCLOSURES_REGEX, ''); + displayName = displayName.replace(UNWANTED_CHARS_REGEX, ''); + displayName = displayName.replace(MULTIPLE_WHITESPACES_REGEX, ' '); + displayName = displayName.trim(); + + return displayName; +} + +/** + * Get (up to 2 characters) initials based on display name of the persona. + * + * @param displayName - The full name of the person or entity + * @param isRtl - Whether the display is in RTL + * @param options - Extra options to control the behavior of getInitials + * + * @returns The 1 or 2 character initials based on the name. Or an empty string if no initials + * could be derived from the name. + * + * @internal + */ +export function getInitials( + displayName: string | undefined | null, + isRtl: boolean, + options?: { + /** Should initials be generated from phone numbers (default false) */ + allowPhoneInitials?: boolean; + + /** Returns only the first initial */ + firstInitialOnly?: boolean; + }, +): string { + if (!displayName) { + return ''; + } + + displayName = cleanupDisplayName(displayName); + + // For names containing CJK characters, and phone numbers, we don't display initials + if ( + UNSUPPORTED_TEXT_REGEX.test(displayName) || + (!options?.allowPhoneInitials && PHONENUMBER_REGEX.test(displayName)) + ) { + return ''; + } + + return getInitialsLatin(displayName, isRtl, options?.firstInitialOnly); +}