Skip to content

Commit

Permalink
feat(web-components): added support for custom icons and colors to ra…
Browse files Browse the repository at this point in the history
…ting-display (#32907)
  • Loading branch information
mlijanto authored Sep 27, 2024
1 parent cc85881 commit 4834ec2
Show file tree
Hide file tree
Showing 7 changed files with 137 additions and 27 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "prerelease",
"comment": "feat: add support for custom icons and colors to rating-display",
"packageName": "@fluentui/web-components",
"email": "[email protected]",
"dependentChangeType": "patch"
}
12 changes: 4 additions & 8 deletions packages/web-components/docs/api-report.md
Original file line number Diff line number Diff line change
Expand Up @@ -657,6 +657,8 @@ export class BaseRatingDisplay extends FASTElement {
generateIcons(): string;
protected getMaxIcons(): number;
protected getSelectedValue(): number;
icon?: string;
iconViewBox?: string;
max?: number;
value?: number;
}
Expand Down Expand Up @@ -2287,10 +2289,7 @@ export const DividerDefinition: FASTElementDefinition<typeof Divider>;

// @public
export const DividerOrientation: {
readonly horizontal: "horizontal"; /**
* Divider roles
* @public
*/
readonly horizontal: "horizontal";
readonly vertical: "vertical";
};

Expand Down Expand Up @@ -3598,10 +3597,7 @@ export const TablistDefinition: FASTElementDefinition<typeof Tablist>;

// @public
export const TablistOrientation: {
readonly horizontal: "horizontal"; /**
* The appearance of the component
* @public
*/
readonly horizontal: "horizontal";
readonly vertical: "vertical";
};

Expand Down
35 changes: 32 additions & 3 deletions packages/web-components/src/rating-display/rating-display.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ test.describe('Rating Display', () => {
await expect(element).toBeVisible();
await expect(element).not.toHaveAttribute('color');
await expect(element).not.toHaveAttribute('compact');
await expect(element).not.toHaveAttribute('count');
await expect(element).not.toHaveAttribute('icon-view-box');
await expect(element).not.toHaveAttribute('max');
await expect(element).not.toHaveAttribute('size');
await expect(page.locator('.count-label')).toBeHidden();
Expand All @@ -29,7 +31,7 @@ test.describe('Rating Display', () => {
await page.setContent(`<fluent-rating-display value="3.5" count="100"></fluent-rating-display>`);

await expect(element).toHaveJSProperty('elementInternals.role', 'img');
await expect(page.locator('svg').last()).toHaveAttribute('aria-hidden', 'true');
await expect(page.locator('svg').first()).toHaveAttribute('aria-hidden', 'true');
await expect(page.locator('.value-label')).toHaveAttribute('aria-hidden', 'true');
await expect(page.locator('.count-label')).toHaveAttribute('aria-hidden', 'true');
});
Expand Down Expand Up @@ -57,7 +59,7 @@ test.describe('Rating Display', () => {
}
});

test('should use the right icon color based on the `icon` attribute', async ({ page }) => {
test('should use the right icon color based on the `color` attribute', async ({ page }) => {
await page.setContent(`<fluent-rating-display value="4"></fluent-rating-display>`);

expect(await element.evaluate((node: RatingDisplay) => node.color)).toBeUndefined();
Expand Down Expand Up @@ -108,7 +110,7 @@ test.describe('Rating Display', () => {

await expect(element).toHaveJSProperty('compact', true);
await expect(page.locator('svg[aria-hidden="true"]')).toHaveCount(2);
await expect(page.locator('svg').last()).toHaveAttribute('selected');
await expect(page.locator('svg[aria-hidden="true"]').last()).toHaveAttribute('selected');
await expect(page.locator('.value-label')).toHaveText('4.5');

for (const icon of await page.locator('svg[aria-hidden="true"]').all()) {
Expand All @@ -124,6 +126,21 @@ test.describe('Rating Display', () => {
await expect(page.locator('.count-label')).toHaveText('1,000');
});

test('should set the correct icon `viewBox` based on the `icon-view-box` attribute', async ({ page }) => {
await page.setContent(`<fluent-rating-display value="2.2"></fluent-rating-display>`);

const icon: Locator = page.locator('svg[aria-hidden="true"]').last();

// Should set the default value when the attribute is not provided
await expect(icon).toHaveAttribute('viewBox', '0 0 20 20');

await element.evaluate((node: RatingDisplay) => {
node.iconViewBox = '0 0 12 12';
});

await expect(icon).toHaveAttribute('viewBox', '0 0 12 12');
});

test('should display the correct number of icons based on the `max` attribute', async ({ page }) => {
await page.setContent(`<fluent-rating-display value="8" max="10"></fluent-rating-display>`);

Expand Down Expand Up @@ -174,4 +191,16 @@ test.describe('Rating Display', () => {
await expect(value).toHaveCSS('line-height', '20px');
await expect(value).toHaveCSS('margin-inline-start', '6px');
});

test('should use custom icons when provided', async ({ page }) => {
await page.setContent(
`<fluent-rating-display value="4.1"><svg slot="icon"><path d="M10 2C5.58172 2 2 5.58172 2 10C2 14.4183 5.58172 18 10 18C14.4183 18 18 14.4183 18 10C18 5.58172 14.4183 2 10 2Z" /></svg></fluent-rating-display>`,
);

const icon: Locator = page.locator('svg[aria-hidden="true"]').last();

// Check for <path> as a direct child to verify that the custom icon is being used
await expect(icon.locator('> path')).toBeVisible();
await expect(icon.locator('> use')).toBeHidden();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ const storyTemplate = html<StoryArgs<FluentRatingDisplay>>`
max=${story => story.max}
size=${story => story.size}
value=${story => story.value}
></fluent-rating-display>
>${story => story.iconSlottedContent?.()}</fluent-rating-display
>
`;

export default {
Expand Down Expand Up @@ -51,6 +52,17 @@ export default {
description: 'The number of ratings',
table: { category: 'attributes', type: { summary: 'number' } },
},
'icon-view-box': {
control: 'text',
table: {
type: {
summary: 'The `viewBox` attribute of the icon SVG element',
},
defaultValue: {
summary: '0 0 20 20',
},
},
},
max: {
control: 'number',
description: 'The maximum possible value of the rating. This attribute determines the number of icons displayed.',
Expand All @@ -61,6 +73,12 @@ export default {
description: 'The value of the rating',
table: { category: 'attributes', type: { summary: 'number' } },
},
iconSlottedContent: {
control: false,
description: 'The slot for the SVG element used as the rating icon',
name: 'icon',
table: { category: 'slots', type: {} },
},
},
} as Meta<FluentRatingDisplay>;

Expand Down Expand Up @@ -118,3 +136,14 @@ export const Compact: Story = {
compact: true,
},
};

export const CustomIcon: Story = {
args: {
value: 3.7,
iconSlottedContent: () => html`<svg slot="icon">
<path
d="M10 2C5.58172 2 2 5.58172 2 10C2 14.4183 5.58172 18 10 18C14.4183 18 18 14.4183 18 10C18 5.58172 14.4183 2 10 2Z"
/>
</svg>`,
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ export const styles = css`
:host {
--icon-size: 16px;
--icon-color-filled: ${colorPaletteMarigoldBackground3};
--icon-color-empty: ${colorPaletteMarigoldBackground2};
align-items: center;
color: ${colorNeutralForeground1};
font-family: ${fontFamilyBase};
Expand All @@ -48,42 +50,46 @@ export const styles = css`
line-height: ${lineHeightBase300};
}
::slotted([slot='icon']) {
display: none;
}
svg {
width: var(--icon-size);
height: var(--icon-size);
fill: ${colorPaletteMarigoldBackground3};
fill: var(--icon-color-filled);
margin-inline-end: ${spacingHorizontalXXS};
}
svg:nth-child(even) {
svg:nth-child(odd) {
clip-path: inset(0 50% 0 0);
margin-inline-end: calc(0px - var(--icon-size));
}
:host(${neutralState}) svg {
fill: ${colorNeutralForeground1};
--icon-color-filled: ${colorNeutralForeground1};
}
:host(${brandState}) svg {
fill: ${colorBrandBackground};
--icon-color-filled: ${colorBrandBackground};
}
:host(:is([value^='-'], [value='0'])) svg,
:host(:not([value])) svg,
svg[selected] ~ svg {
fill: ${colorPaletteMarigoldBackground2};
fill: var(--icon-color-empty);
}
:host(${neutralState}:is([value^='-'], [value='0'])) svg,
:host(${neutralState}:not([value])) svg,
:host(${neutralState}) svg[selected] ~ svg {
fill: ${colorNeutralBackground1Pressed};
--icon-color-empty: ${colorNeutralBackground1Pressed};
}
:host(${brandState}:is([value^='-'], [value='0'])) svg,
:host(${brandState}:not([value])) svg,
:host(${brandState}) svg[selected] ~ svg {
fill: ${colorBrandStroke2};
--icon-color-empty: ${colorBrandStroke2};
}
.value-label,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import { ElementViewTemplate, html } from '@microsoft/fast-element';
import { elements, ElementViewTemplate, html, slotted } from '@microsoft/fast-element';
import { staticallyCompose } from '../utils/template-helpers.js';
import type { RatingDisplay } from './rating-display.js';

/**
* Reusable star icon symbol
*/
const star = html`
<svg xmlns="http://www.w3.org/2000/svg" style="display: none">
<symbol id="star" viewBox="0 0 12 12">
<symbol id="star">
<path
d="M5.28347 1.54556C5.57692 0.95096 6.42479 0.950961 6.71825 1.54556L7.82997 3.79817L10.3159 4.15939C10.9721 4.25474 11.2341 5.06112 10.7592 5.52394L8.96043 7.27736L9.38507 9.75321C9.49716 10.4067 8.81122 10.9051 8.22431 10.5966L6.00086 9.42761L3.7774 10.5966C3.19049 10.9051 2.50455 10.4067 2.61664 9.75321L3.04128 7.27736L1.24246 5.52394C0.767651 5.06111 1.02966 4.25474 1.68584 4.15939L4.17174 3.79817L5.28347 1.54556Z"
d="M9.10433 2.89874C9.47114 2.15549 10.531 2.1555 10.8978 2.89874L12.8282 6.81024L17.1448 7.43748C17.9651 7.55666 18.2926 8.56464 17.699 9.14317L14.5755 12.1878L15.3129 16.487C15.453 17.3039 14.5956 17.9269 13.8619 17.5412L10.0011 15.5114L6.14018 17.5412C5.40655 17.9269 4.54913 17.3039 4.68924 16.487L5.4266 12.1878L2.30308 9.14317C1.70956 8.56463 2.03708 7.55666 2.8573 7.43748L7.17389 6.81024L9.10433 2.89874Z"
/>
</symbol>
</svg>
Expand All @@ -21,7 +22,8 @@ const star = html`
*/
export function ratingDisplayTemplate<T extends RatingDisplay>(): ElementViewTemplate<T> {
return html<T>`
${star} ${x => html`${html.partial(x.generateIcons())}`}
${x => html`${staticallyCompose(x.generateIcons())}`}
<slot name="icon" ${slotted({ property: 'slottedIcon', filter: elements('svg') })}>${star}</slot>
<slot name="value"><span class="value-label" aria-hidden="true">${x => x.value}</span></slot>
<slot name="count"><span class="count-label" aria-hidden="true">${x => x.formattedCount}</span></slot>
`;
Expand Down
49 changes: 45 additions & 4 deletions packages/web-components/src/rating-display/rating-display.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { attr, FASTElement, nullableNumberConverter } from '@microsoft/fast-element';
import { attr, FASTElement, nullableNumberConverter, observable } from '@microsoft/fast-element';
import { toggleState } from '../utils/element-internals.js';
import { RatingDisplayColor, RatingDisplaySize } from './rating-display.options.js';

/**
* The base class used for constructing a fluent-rating-display custom element
*
* @slot icon - SVG element used as the rating icon
*
* @public
*/
export class BaseRatingDisplay extends FASTElement {
Expand All @@ -24,6 +27,17 @@ export class BaseRatingDisplay extends FASTElement {
@attr({ converter: nullableNumberConverter })
public count?: number;

/**
* The `viewBox` attribute of the icon <svg> element.
*
* @public
* @default `0 0 20 20`
* @remarks
* HTML Attribute: `icon-view-box`
*/
@attr({ attribute: 'icon-view-box' })
iconViewBox?: string;

/**
* The maximum possible value of the rating.
* This attribute determines the number of icons displayed.
Expand All @@ -46,6 +60,27 @@ export class BaseRatingDisplay extends FASTElement {
@attr({ converter: nullableNumberConverter })
public value?: number;

/**
* @internal
*/
@observable
public slottedIcon!: HTMLElement[];

/**
* @internal
*/
public slottedIconChanged(): void {
if (this.$fastController.isConnected) {
this.customIcon = this.slottedIcon[0]?.outerHTML;
}
}

/**
* @internal
*/
@observable
private customIcon?: string;

private intlNumberFormatter = new Intl.NumberFormat();

constructor() {
Expand Down Expand Up @@ -88,6 +123,12 @@ export class BaseRatingDisplay extends FASTElement {
*/
public generateIcons(): string {
let htmlString: string = '';
let customIcon: string | undefined;

if (this.customIcon) {
// Extract the SVG element content
customIcon = /<svg[^>]*>([\s\S]*?)<\/svg>/.exec(this.customIcon)?.[1] ?? '';
}

// The value of the selected icon. Based on the "value" attribute, rounded to the nearest half.
const selectedValue: number = this.getSelectedValue();
Expand All @@ -96,17 +137,17 @@ export class BaseRatingDisplay extends FASTElement {
for (let i: number = 0; i < this.getMaxIcons(); i++) {
const iconValue: number = (i + 1) / 2;

htmlString += `<svg aria-hidden="true" ${
htmlString += `<svg aria-hidden="true" viewBox="${this.iconViewBox ?? '0 0 20 20'}" ${
iconValue === selectedValue ? 'selected' : ''
}><use href="#star"></use></svg>`;
}>${customIcon ?? '<use href="#star"></use>'}</svg>`;
}

return htmlString;
}
}

/**
* A Rating Dislpay Custom HTML Element.
* A Rating Display Custom HTML Element.
* Based on BaseRatingDisplay and includes style and layout specific attributes
*
* @public
Expand Down

0 comments on commit 4834ec2

Please sign in to comment.