Skip to content

Commit 3ed039a

Browse files
authored
feat: make rating display ssr compatible (#35126)
1 parent 7b9c5df commit 3ed039a

File tree

7 files changed

+245
-234
lines changed

7 files changed

+245
-234
lines changed
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"type": "prerelease",
3+
"comment": "feat: enable SSR for rating-display",
4+
"packageName": "@fluentui/web-components",
5+
"email": "machi@microsoft.com",
6+
"dependentChangeType": "patch"
7+
}

packages/web-components/docs/web-components.api.md

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -730,22 +730,35 @@ export class BaseProgressBar extends FASTElement {
730730
// @public
731731
export class BaseRatingDisplay extends FASTElement {
732732
constructor();
733+
// (undocumented)
734+
connectedCallback(): void;
733735
count?: number;
736+
// (undocumented)
737+
protected defaultCustomIconViewBox: string;
738+
// @internal
739+
display: HTMLElement;
734740
// @internal
735741
elementInternals: ElementInternals;
736742
// @internal
737743
get formattedCount(): string;
738-
// @internal
739-
generateIcons(): string;
740-
protected getMaxIcons(): number;
741-
protected getSelectedValue(): number;
742-
iconViewBox?: string;
743-
max?: number;
744744
// @internal (undocumented)
745-
slottedIcon: HTMLElement[];
745+
handleSlotChange(): void;
746746
// @internal (undocumented)
747-
slottedIconChanged(): void;
747+
iconSlot: HTMLSlotElement;
748+
// @deprecated
749+
iconViewBox?: string;
750+
max?: number;
751+
// (undocumented)
752+
protected maxChanged(): void;
753+
// (undocumented)
754+
protected renderSlottedIcon(svg: SVGSVGElement | null): void;
755+
// Warning: (ae-forgotten-export) The symbol "PropertyNameForCalculation" needs to be exported by the entry point index.d.ts
756+
//
757+
// (undocumented)
758+
protected setCustomPropertyValue(propertyName: PropertyNameForCalculation): void;
748759
value?: number;
760+
// (undocumented)
761+
protected valueChanged(): void;
749762
}
750763

751764
// @public
@@ -3415,10 +3428,6 @@ export const RadioTemplate: ElementViewTemplate<Radio>;
34153428
export class RatingDisplay extends BaseRatingDisplay {
34163429
color?: RatingDisplayColor;
34173430
compact: boolean;
3418-
// @override
3419-
protected getMaxIcons(): number;
3420-
// @override
3421-
protected getSelectedValue(): number;
34223431
size?: RatingDisplaySize;
34233432
}
34243433

Lines changed: 83 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,21 @@
1-
import { attr, FASTElement, nullableNumberConverter, observable } from '@microsoft/fast-element';
1+
import { attr, FASTElement, nullableNumberConverter } from '@microsoft/fast-element';
2+
3+
const SUPPORTS_ATTR_TYPE = CSS.supports('width: attr(value type(<number>))');
4+
const CUSTOM_PROPERTY_NAME = {
5+
max: '--_attr-max',
6+
value: '--_attr-value',
7+
maskImageFilled: '--_mask-image-filled',
8+
maskImageOutlined: '--_mask-image-outlined',
9+
};
10+
type PropertyNameForCalculation = 'max' | 'value';
11+
12+
export function svgToDataURI(svg: string) {
13+
if (!svg) {
14+
return '';
15+
}
16+
17+
return ['data:image/svg+xml', encodeURIComponent(svg.replace(/\n/g, '').replace(/\s+/g, ' '))].join(',');
18+
}
219

320
/**
421
* The base class used for constructing a fluent-rating-display custom element
@@ -8,13 +25,26 @@ import { attr, FASTElement, nullableNumberConverter, observable } from '@microso
825
* @public
926
*/
1027
export class BaseRatingDisplay extends FASTElement {
28+
private numberFormatter!: Intl.NumberFormat;
29+
1130
/**
1231
* The internal {@link https://developer.mozilla.org/docs/Web/API/ElementInternals | `ElementInternals`} instance for the component.
1332
*
1433
* @internal
1534
*/
1635
public elementInternals: ElementInternals = this.attachInternals();
1736

37+
/** @internal */
38+
public iconSlot!: HTMLSlotElement;
39+
40+
protected defaultCustomIconViewBox = '0 0 20 20';
41+
42+
/**
43+
* The element that displays the rating icons.
44+
* @internal
45+
*/
46+
public display!: HTMLElement;
47+
1848
/**
1949
* The number of ratings.
2050
*
@@ -29,9 +59,9 @@ export class BaseRatingDisplay extends FASTElement {
2959
* The `viewBox` attribute of the icon <svg> element.
3060
*
3161
* @public
32-
* @default `0 0 20 20`
3362
* @remarks
3463
* HTML Attribute: `icon-view-box`
64+
* @deprecated Add `viewBox` attribute on the custom SVG directly.
3565
*/
3666
@attr({ attribute: 'icon-view-box' })
3767
iconViewBox?: string;
@@ -47,6 +77,9 @@ export class BaseRatingDisplay extends FASTElement {
4777
*/
4878
@attr({ converter: nullableNumberConverter })
4979
public max?: number;
80+
protected maxChanged() {
81+
this.setCustomPropertyValue('max');
82+
}
5083

5184
/**
5285
* The value of the rating.
@@ -57,34 +90,21 @@ export class BaseRatingDisplay extends FASTElement {
5790
*/
5891
@attr({ converter: nullableNumberConverter })
5992
public value?: number;
60-
61-
/**
62-
* @internal
63-
*/
64-
@observable
65-
public slottedIcon!: HTMLElement[];
66-
67-
/**
68-
* @internal
69-
*/
70-
public slottedIconChanged(): void {
71-
if (this.$fastController.isConnected) {
72-
this.customIcon = this.slottedIcon[0]?.outerHTML;
73-
}
93+
protected valueChanged() {
94+
this.setCustomPropertyValue('value');
7495
}
7596

76-
/**
77-
* @internal
78-
*/
79-
@observable
80-
private customIcon?: string;
81-
82-
private intlNumberFormatter = new Intl.NumberFormat();
83-
8497
constructor() {
8598
super();
8699

87100
this.elementInternals.role = 'img';
101+
this.numberFormatter = new Intl.NumberFormat();
102+
}
103+
104+
connectedCallback() {
105+
super.connectedCallback();
106+
this.setCustomPropertyValue('value');
107+
this.setCustomPropertyValue('max');
88108
}
89109

90110
/**
@@ -93,53 +113,54 @@ export class BaseRatingDisplay extends FASTElement {
93113
* @internal
94114
*/
95115
public get formattedCount(): string {
96-
return this.count ? this.intlNumberFormatter.format(this.count) : '';
116+
return this.count ? this.numberFormatter.format(this.count) : '';
97117
}
98118

99-
/**
100-
* Gets the selected value
101-
*
102-
* @protected
103-
*/
104-
protected getSelectedValue(): number {
105-
return Math.round((this.value ?? 0) * 2) / 2;
106-
}
119+
/** @internal */
120+
public handleSlotChange() {
121+
const icon = this.iconSlot.assignedElements()?.find(el => el.nodeName.toLowerCase() === 'svg') as SVGSVGElement;
107122

108-
/**
109-
* Gets the maximum icons to render
110-
*
111-
* @protected
112-
*/
113-
protected getMaxIcons(): number {
114-
return (this.max ?? 5) * 2;
123+
this.renderSlottedIcon(icon ?? null);
115124
}
116125

117-
/**
118-
* Generates the icon SVG elements based on the "max" attribute.
119-
*
120-
* @internal
121-
*/
122-
public generateIcons(): string {
123-
let htmlString: string = '';
124-
let customIcon: string | undefined;
125-
126-
if (this.customIcon) {
127-
// Extract the SVG element content
128-
customIcon = /<svg[^>]*>([\s\S]*?)<\/svg>/.exec(this.customIcon)?.[1] ?? '';
126+
protected renderSlottedIcon(svg: SVGSVGElement | null) {
127+
if (!svg) {
128+
this.display.style.removeProperty(CUSTOM_PROPERTY_NAME.maskImageFilled);
129+
this.display.style.removeProperty(CUSTOM_PROPERTY_NAME.maskImageOutlined);
130+
return;
129131
}
130132

131-
// The value of the selected icon. Based on the "value" attribute, rounded to the nearest half.
132-
const selectedValue: number = this.getSelectedValue();
133-
134-
// Render the icons based on the "max" attribute. If "max" is not set, render 5 icons.
135-
for (let i: number = 0; i < this.getMaxIcons(); i++) {
136-
const iconValue: number = (i + 1) / 2;
133+
const innerSvg = svg.innerHTML;
134+
const viewBox = svg.getAttribute('viewBox') ?? this.iconViewBox ?? this.defaultCustomIconViewBox;
135+
136+
const customSvgFilled = `
137+
<svg
138+
viewBox="${viewBox}"
139+
xmlns="http://www.w3.org/2000/svg"
140+
>${innerSvg}</svg>`;
141+
const customSvgOutlined = `
142+
<svg
143+
viewBox="${viewBox}"
144+
xmlns="http://www.w3.org/2000/svg"
145+
fill="none"
146+
stroke="black"
147+
stroke-width="2"
148+
>${innerSvg}</svg>`;
149+
this.display.style.setProperty(CUSTOM_PROPERTY_NAME.maskImageFilled, `url(${svgToDataURI(customSvgFilled)})`);
150+
this.display.style.setProperty(CUSTOM_PROPERTY_NAME.maskImageOutlined, `url(${svgToDataURI(customSvgOutlined)})`);
151+
}
137152

138-
htmlString += `<svg aria-hidden="true" viewBox="${this.iconViewBox ?? '0 0 20 20'}" ${
139-
iconValue === selectedValue ? 'selected' : ''
140-
}>${customIcon ?? '<use href="#star"></use>'}</svg>`;
153+
protected setCustomPropertyValue(propertyName: PropertyNameForCalculation) {
154+
if (!this.display || SUPPORTS_ATTR_TYPE) {
155+
return;
141156
}
142157

143-
return htmlString;
158+
const propertyValue = this[propertyName];
159+
160+
if (typeof propertyValue !== 'number' || Number.isNaN(propertyValue)) {
161+
this.display.style.removeProperty(CUSTOM_PROPERTY_NAME[propertyName]);
162+
} else {
163+
this.display.style.setProperty(CUSTOM_PROPERTY_NAME[propertyName], `${propertyValue}`);
164+
}
144165
}
145166
}

0 commit comments

Comments
 (0)