diff --git a/change/@fluentui-web-components-a862230b-9028-4c6a-8a43-3be819a6128e.json b/change/@fluentui-web-components-a862230b-9028-4c6a-8a43-3be819a6128e.json new file mode 100644 index 00000000000000..5dc002fd61244f --- /dev/null +++ b/change/@fluentui-web-components-a862230b-9028-4c6a-8a43-3be819a6128e.json @@ -0,0 +1,7 @@ +{ + "type": "prerelease", + "comment": "feat(image): Add image web component", + "packageName": "@fluentui/web-components", + "email": "harankin@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/packages/web-components/package.json b/packages/web-components/package.json index 5c9856fd725487..2652825beb7125 100644 --- a/packages/web-components/package.json +++ b/packages/web-components/package.json @@ -40,6 +40,10 @@ "types": "./dist/esm/counter-badge/define.d.ts", "default": "./dist/esm/counter-badge/define.js" }, + "./image": { + "types": "./dist/esm/image/define.d.ts", + "default": "./dist/esm/image/define.js" + }, "./text": { "types": "./dist/esm/text/define.d.ts", "default": "./dist/esm/text/define.js" diff --git a/packages/web-components/src/image/define.ts b/packages/web-components/src/image/define.ts new file mode 100644 index 00000000000000..002c77e34e5228 --- /dev/null +++ b/packages/web-components/src/image/define.ts @@ -0,0 +1,4 @@ +import { FluentDesignSystem } from '../fluent-design-system.js'; +import { definition } from './image.definition.js'; + +definition.define(FluentDesignSystem.registry); diff --git a/packages/web-components/src/image/image.definition.ts b/packages/web-components/src/image/image.definition.ts new file mode 100644 index 00000000000000..4e452fcc3a13ec --- /dev/null +++ b/packages/web-components/src/image/image.definition.ts @@ -0,0 +1,17 @@ +import { FluentDesignSystem } from '../fluent-design-system.js'; +import { Image } from './image.js'; +import { template } from './image.template.js'; +import { styles } from './image.styles.js'; + +/** + * The Fluent Image Element + * + * @public + * @remarks + * HTML Element: \ + */ +export const definition = Image.compose({ + name: `${FluentDesignSystem.prefix}-image`, + template, + styles, +}); diff --git a/packages/web-components/src/image/image.options.ts b/packages/web-components/src/image/image.options.ts new file mode 100644 index 00000000000000..fe68ac66892e19 --- /dev/null +++ b/packages/web-components/src/image/image.options.ts @@ -0,0 +1,30 @@ +import { ValuesOf } from '@microsoft/fast-foundation'; + +/** + * Image fit + * @public + */ +export const ImageFit = { + none: 'none', + center: 'center', + contain: 'contain', + cover: 'cover', + default: 'default', +} as const; +/** + * Types for image fit + * @public + */ +export type ImageFit = ValuesOf; + +/** + * Image shape + * @public + */ +export const ImageShape = { + circular: 'circular', + rounded: 'rounded', + square: 'square', +} as const; + +export type ImageShape = ValuesOf; diff --git a/packages/web-components/src/image/image.spec.md b/packages/web-components/src/image/image.spec.md new file mode 100644 index 00000000000000..d2dba58db8142a --- /dev/null +++ b/packages/web-components/src/image/image.spec.md @@ -0,0 +1,62 @@ +# Fluent Image Component + +## Component Description + +Images, like photos and illustrations, help reinforce a message and express your product or app’s style. + +## Design Spec + +[Image Spec in Figma](https://www.figma.com/file/05wt6TAsEmgsCVZfPrpcWx/Image?t=uEvu1KnTefdTZHJC-6) + +## Engineering Spec + +### Inputs + +**content** + +- @attr public alt: string | Requires description if image role is not set to presentation. +- @attr public role: string +- @attr public src: string + +**booleans** + +- @attr public block: boolean | false +- @attr public border: boolean | false +- @attr public shadow: boolean | false + +**options** + +- @attr public fit: 'none' | 'center' | 'contain' | 'cover' | 'default' +- @attr public shape: 'square' | 'rounded' | 'circular' + +### Slots + +1 slot for developer to add element. + +## Accessibility + +The image element requires an alt tag when not used in role: presentation. + +## Preparation + +This will extend the FASTElement. + +Open GitHub issues related to Image component + +- [Feature request](https://github.com/microsoft/fluentui/issues/26452) +- [Bug](https://github.com/microsoft/fluentui/issues/26399) + +## Implementation + +### CSS Guidance + +- [x] Uses design tokens for styling + +An optional border-radius can be expressed using the following design tokens: + +- borderRadiusSmall, +- borderRadiusMedium, +- borderRadiusLarge +- borderRadiusXLarge + +An optional 16px margin can be added to the image to separate it from surrounding content. diff --git a/packages/web-components/src/image/image.stories.ts b/packages/web-components/src/image/image.stories.ts new file mode 100644 index 00000000000000..b5ed33ed14daa0 --- /dev/null +++ b/packages/web-components/src/image/image.stories.ts @@ -0,0 +1,230 @@ +import { html } from '@microsoft/fast-element'; +import type { Args, Meta } from '@storybook/html'; +import { renderComponent } from '../helpers.stories.js'; +import type { Image as FluentImage } from './image.js'; +import { ImageFit, ImageShape } from './image.options.js'; +import './define.js'; + +type ImageStoryArgs = Args & FluentImage; +type ImageStoryMeta = Meta; + +const imageTemplate = html` +
+ x.block} + ?bordered=${x => x.bordered} + fit=${x => x.fit} + ?shadow=${x => x.shadow} + shape=${x => x.shape} + > + Short image description + +
+`; + +export default { + title: 'Components/Image', + args: { + block: false, + bordered: false, + shadow: false, + fit: ImageFit.default, + shape: ImageShape.square, + }, + argTypes: { + alt: { + description: 'Alternate text description -- to be supplied by component consumer', + table: { + type: { + summary: + 'Required. Alt tag provides text attribution for images. Should be brief but accurate—one or two sentences that describe the image and its context. If the image represents a function, be sure to indicate that. If it’s meant to be consumed with other objects on the page, consider that as well. Don’t repeat information that’s on the page in alt text since screen readers will read it twice.', + }, + }, + }, + block: { + description: + 'An image can use the argument ‘block’ so that it’s width will expand to fiill the available container space.', + table: { + defaultValue: { + summary: false, + }, + }, + }, + bordered: { + description: 'Border surrounding image', + table: { + type: { + summary: 'Use this option to provide minimal visual separation between image and surrounding content.', + }, + defaultValue: { + summary: false, + }, + }, + }, + fit: { + description: 'Determines how the image will be scaled and positioned within its parent container.', + table: { + defaultValue: { + summary: 'default', + }, + }, + options: Object.values(ImageFit), + control: 'select', + }, + role: { + description: 'Aria role -- to be supplied by component consumer', + table: { + type: { + summary: + 'If images are solely decorative and don’t provide useful information or context, use role=”presentation” to hide them from assistive technologies.', + }, + }, + }, + shadow: { + description: 'Apply an optional box shadow to further separate the image from the background.', + table: { + type: { + summary: + 'To give an image additional prominence, use the shadow prop to make it appear elevated. Too many shadows can cause a busy layout, so use them sparingly.', + }, + defaultValue: { + summary: false, + }, + }, + }, + shape: { + description: 'Image shape', + table: { + defaultValue: { + summary: 'square', + }, + }, + options: Object.values(ImageShape), + control: 'select', + }, + src: { + description: 'Image source -- to be supplied by component consumer', + table: { + type: { + summary: 'Required', + }, + }, + }, + }, +} as ImageStoryMeta; + +export const Image = renderComponent(imageTemplate).bind({}); + +// Block layout +const imageLayoutBlock = html` +
+ + + + +
+`; +export const BlockLayout = renderComponent(imageLayoutBlock).bind({}); + +// Fit: None +const imageFitNoneLarge = html` +
+ + + +
+`; +export const ImageFitNoneLarge = renderComponent(imageFitNoneLarge).bind({}); + +const imageFitNoneSmall = html` +
+ + 200x100 placeholder + +
+`; +export const ImageFitNoneSmall = renderComponent(imageFitNoneSmall).bind({}); + +// Fit: Center +const imageFitCenterLarge = html` +
+ + + +
+`; +export const ImageFitCenterLarge = renderComponent(imageFitCenterLarge).bind({}); + +const imageFitCenterSmall = html` +
+ + image layout story + +
+`; +export const ImageFitCenterSmall = renderComponent(imageFitCenterSmall).bind({}); + +const imageFitContain = html` +
+ + image layout story + +
+`; +export const ImageFitContain = renderComponent(imageFitContain).bind({}); + +const imageFitContainTall = html` +
+ + image layout story + +
+`; +export const ImageFitContainTall = renderComponent(imageFitContainTall).bind({}); + +const imageFitContainWide = html` +
+ + image layout story + +
+`; +export const ImageFitContainWide = renderComponent(imageFitContainWide).bind({}); + +// Fit: Cover +const imageFitCoverSmall = html` +
+ + image layout story + +
+`; +export const ImageFitCoverSmall = renderComponent(imageFitCoverSmall).bind({}); + +const imageFitCoverMedium = html` +
+ + image layout story + +
+`; +export const ImageFitCoverMedium = renderComponent(imageFitCoverMedium).bind({}); + +const imageFitCoverLarge = html` +
+ + image layout story + +
+`; +export const ImageFitCoverLarge = renderComponent(imageFitCoverLarge).bind({}); + +// Fit: Default +const imageFitDefault = html` +
+ + image layout story + +
+`; +export const ImageFitDefault = renderComponent(imageFitDefault).bind({}); diff --git a/packages/web-components/src/image/image.styles.ts b/packages/web-components/src/image/image.styles.ts new file mode 100644 index 00000000000000..34c04cf72590e0 --- /dev/null +++ b/packages/web-components/src/image/image.styles.ts @@ -0,0 +1,52 @@ +import { css } from '@microsoft/fast-element'; +import { borderRadiusCircular, colorNeutralStroke2, shadow4, strokeWidthThin } from '../theme/design-tokens.js'; + +/** Image styles + * + * @public + */ +export const styles = css` + :host ::slotted(img) { + box-sizing: border-box; + min-height: 8px; + min-width: 8px; + display: inline-block; + } + :host([block]) ::slotted(img) { + width: 100%; + height: auto; + } + :host([bordered]) ::slotted(img) { + border: ${strokeWidthThin} solid ${colorNeutralStroke2}; + } + :host([fit='none']) ::slotted(img) { + object-fit: none; + object-position: top left; + height: 100%; + width: 100%; + } + :host([fit='center']) ::slotted(img) { + object-fit: none; + object-position: center; + height: 100%; + width: 100%; + } + :host([fit='contain']) ::slotted(img) { + object-fit: contain; + object-position: center; + height: 100%; + width: 100%; + } + :host([fit='cover']) ::slotted(img) { + object-fit: cover; + object-position: center; + height: 100%; + width: 100%; + } + :host([shadow]) ::slotted(img) { + box-shadow: ${shadow4}; + } + :host([shape='circular']) ::slotted(img) { + border-radius: ${borderRadiusCircular}; + } +`; diff --git a/packages/web-components/src/image/image.template.ts b/packages/web-components/src/image/image.template.ts new file mode 100644 index 00000000000000..9307a3c7d72eac --- /dev/null +++ b/packages/web-components/src/image/image.template.ts @@ -0,0 +1,8 @@ +import { ElementViewTemplate, html } from '@microsoft/fast-element'; +import type { Image } from './image.js'; + +/** + * Template for the Image component + * @public + */ +export const template: ElementViewTemplate = html``; diff --git a/packages/web-components/src/image/image.ts b/packages/web-components/src/image/image.ts new file mode 100644 index 00000000000000..c93f4d22d4959c --- /dev/null +++ b/packages/web-components/src/image/image.ts @@ -0,0 +1,54 @@ +import { attr, FASTElement } from '@microsoft/fast-element'; +import { ImageFit, ImageShape } from './image.options.js'; + +/** + * The base class used for constucting a fluent image custom element + * @public + */ +export class Image extends FASTElement { + /** + * Image layout + * + * @public + * @remarks + * HTML attribute: block. + */ + @attr({ mode: 'boolean' }) + public block?: boolean; + /** + * Image border + * + * @public + * @remarks + * HTML attribute: border. + */ + @attr({ mode: 'boolean' }) + public bordered?: boolean; + /** + * Image shadow + * + * @public + * @remarks + * HTML attribute: shadow. + */ + @attr({ mode: 'boolean' }) + public shadow?: boolean; + /** + * Image fit + * + * @public + * @remarks + * HTML attribute: fit. + */ + @attr + public fit?: ImageFit; + /** + * Image shape + * + * @public + * @remarks + * HTML attribute: shape. + */ + @attr + public shape?: ImageShape; +} diff --git a/packages/web-components/src/image/index.ts b/packages/web-components/src/image/index.ts new file mode 100644 index 00000000000000..29839a12488658 --- /dev/null +++ b/packages/web-components/src/image/index.ts @@ -0,0 +1,5 @@ +export * from './image.js'; +export * from './image.options.js'; +export { definition as ImageDefinition } from './image.definition.js'; +export { template as ImageTemplate } from './image.template.js'; +export { styles as ImageStyles } from './image.styles.js'; diff --git a/packages/web-components/src/index.ts b/packages/web-components/src/index.ts index f259843fc5e6fb..eaf2580651e94a 100644 --- a/packages/web-components/src/index.ts +++ b/packages/web-components/src/index.ts @@ -2,6 +2,7 @@ export * from './accordion/index.js'; export * from './accordion-item/index.js'; export * from './badge/index.js'; export * from './counter-badge/index.js'; +export * from './image/index.js'; export * from './progress-bar/index.js'; export * from './spinner/index.js'; export * from './switch/index.js';