microsoft / fluentui

Fluent UI web represents a collection of utilities, React components, and web components for building web applications.
https://react.fluentui.dev
Other
18.29k stars 2.71k forks source link

[Feature]: Provide a stylesheet for loading Segoe UI fonts #27673

Open NotWoods opened 1 year ago

NotWoods commented 1 year ago

Library

React Components / v9 (@fluentui/react-components)

Describe the feature that you would like added

Fluent UI v0 and v8 currently auto-insert @font-face rules into the document. With apps now switching fully to v9, there should be some equivalent functionality. It should be optional since apps like Teams may provide their own fonts.

Have you discussed this feature with our team

layershifter

Additional context

No response

Validations

benbrown commented 1 year ago

+1 Is there a recommended work around for this?

layershifter commented 1 year ago

I spent some time looking on this and here are some concerns/observations related to this.

Both Fluent UI React v0 & v8 are using runtime CSS-in-JS, that allows them to do two things: insertion of @font-face & changes to tokens.

Font face insertion

That part is more obvious, we just need to insert a set of @font-face definitions. As the list is static and does not depend on language we can insert it in SSR safe way 😻 I have a PoC that does it, TL;DR it works similarly to FluentProvider and CSS variables injection.

The question is what fonts to inject and what to do? read below 🔽

v0

v0 inject just Segoe UI without locale specific symbols (just West European option):

https://github.com/microsoft/fluentui/blob/80d288fd44a79ea445a40ef7659a583d02040821/packages/fluentui/react-northstar/src/themes/teams/fontFaces.ts#L4C4-L24

On partner side that was not enough and fonts were loaded from a different CDN. Now fonts are not loaded at all and use local copies of fonts.

v8

v0 loads a lot of fonts and not only Segoe UI, BTW. Side note: this is probably not compatible with Fluent 2.

https://github.com/microsoft/fluentui/blob/80d288fd44a79ea445a40ef7659a583d02040821/packages/theme/src/fonts/DefaultFontStyles.ts#L60-L78

It also creates runtime font tokens:

https://github.com/microsoft/fluentui/blob/80d288fd44a79ea445a40ef7659a583d02040821/packages/theme/src/fonts/createFontStyles.ts#L76-L79

Note: These tokens are created based on language, the logic that detects language can't be ever SSR safe and will create mismatches on a client/server with v9.

For v9 we will have to override theme to inject a different font-family:

https://github.com/microsoft/fluentui/blob/80d288fd44a79ea445a40ef7659a583d02040821/packages/tokens/src/global/fonts.ts#L39-L45


This leads to following questions:

@NotWoods @benbrown can you please share your expectations there?

NotWoods commented 1 year ago

The Fluent 2 docs indicate to me that Fluent should default to native fonts (as it does today) but offer the option to use Segoe UI everywhere. I feel that this should be offered using a function or CSS file that can be imported from a standard Fluent package. It could be separate to react-components.

I believe unicode-range could be used to select the best font to display some corresponding language's characters, rather than select fonts based on language. That lets us use localized fonts (smaller download size) without relying on user's locale choice.

It's fine to use the same font for all text sizes as Fluent currently does. Fonts can handle that themselves using optical-size.

layershifter commented 1 year ago

@NotWoods thanks for feedback.

Actions there:

PoC

import * as React from 'react';
import { useId, useIsomorphicLayoutEffect } from '@fluentui/react-utilities';
import { useRenderer_unstable } from '@griffel/react';
import { useFluent_unstable as useFluent } from '@fluentui/react-shared-contexts';

const DEFAULT_CDN_BASE_URL = 'https://res-1.cdn.office.net/files/fabric-cdn-prod_20221209.001/assets/fonts';

const LOCALIZED_FONT_NAMES = {
  Arabic: 'Segoe UI Web (Arabic)',
  Cyrillic: 'Segoe UI Web (Cyrillic)',
  EastEuropean: 'Segoe UI Web (East European)',
  Greek: 'Segoe UI Web (Greek)',
  Hebrew: 'Segoe UI Web (Hebrew)',
  Vietnamese: 'Segoe UI Web (Vietnamese)',
  WestEuropean: 'Segoe UI Web (West European)',
};
const LocalizedFontFamilies = {
  Arabic: `'${LOCALIZED_FONT_NAMES.Arabic}'`,
  Cyrillic: `'${LOCALIZED_FONT_NAMES.Cyrillic}'`,
  EastEuropean: `'${LOCALIZED_FONT_NAMES.EastEuropean}'`,
  Greek: `'${LOCALIZED_FONT_NAMES.Greek}'`,
  Hebrew: `'${LOCALIZED_FONT_NAMES.Hebrew}'`,
  WestEuropean: `'${LOCALIZED_FONT_NAMES.WestEuropean}'`,
};

const FONT_WEIGHTS = {
  light: 100,
  semilight: 300,
  regular: 400,
  semibold: 600,
  bold: 700,
};

function createFontFaceRule(
  fontFamily: string,
  url: string,
  fontWeight?: React.CSSProperties['fontWeight'],
  localFontName?: string,
): string {
  fontFamily = `'${fontFamily}'`;

  const localFontSrc = localFontName !== undefined ? `local('${localFontName}'),` : '';

  return [
    '@font-face{',
    `font-family: ${fontFamily};`,
    `src: ${localFontSrc} url('${url}.woff2') format('woff2'), url('${url}.woff') format('woff')`,
    `font-weight: ${fontWeight}`,
    `font-style: 'normal'`,
    `font-display: swap`,
    '}',
  ].join('');
}

function createFontFaceRuleSet(
  baseUrl: string,
  fontFamily: string,
  cdnFolder: string,
  cdnFontName: string = 'segoeui',
  localFontName?: string,
): string {
  const urlBase = `${baseUrl}/${cdnFolder}/${cdnFontName}`;

  return [
    createFontFaceRule(fontFamily, urlBase + '-light', FONT_WEIGHTS.light, localFontName && localFontName + ' Light'),
    createFontFaceRule(
      fontFamily,
      urlBase + '-semilight',
      FONT_WEIGHTS.semilight,
      localFontName && localFontName + ' SemiLight',
    ),
    createFontFaceRule(fontFamily, urlBase + '-regular', FONT_WEIGHTS.regular, localFontName),
    createFontFaceRule(
      fontFamily,
      urlBase + '-semibold',
      FONT_WEIGHTS.semibold,
      localFontName && localFontName + ' SemiBold',
    ),
    createFontFaceRule(fontFamily, urlBase + '-bold', FONT_WEIGHTS.bold, localFontName && localFontName + ' Bold'),
  ].join('');
}

function createDefaultFontFaces(fontBaseUrl: string): string {
  return [
    createFontFaceRuleSet(fontBaseUrl, LOCALIZED_FONT_NAMES.Arabic, 'segoeui-arabic'),
    createFontFaceRuleSet(fontBaseUrl, LOCALIZED_FONT_NAMES.Cyrillic, 'segoeui-cyrillic'),
    createFontFaceRuleSet(fontBaseUrl, LOCALIZED_FONT_NAMES.EastEuropean, 'segoeui-easteuropean'),
    createFontFaceRuleSet(fontBaseUrl, LOCALIZED_FONT_NAMES.Greek, 'segoeui-greek'),
    createFontFaceRuleSet(fontBaseUrl, LOCALIZED_FONT_NAMES.Hebrew, 'segoeui-hebrew'),
    createFontFaceRuleSet(
      fontBaseUrl,
      LOCALIZED_FONT_NAMES.WestEuropean,
      'segoeui-westeuropean',
      'segoeui',
      'Segoe UI',
    ),
  ].join('');
}

// ---

// String concatenation is used to prevent bundlers to complain with older versions of React
const useInsertionEffect = (React as never)['useInsertion' + 'Effect']
  ? (React as never)['useInsertion' + 'Effect']
  : useIsomorphicLayoutEffect;

const createStyleTag = (target: Document | undefined, elementAttributes: Record<string, string>) => {
  if (!target) {
    return undefined;
  }

  const tag = target.createElement('style');

  Object.keys(elementAttributes).forEach(attrName => {
    tag.setAttribute(attrName, elementAttributes[attrName]);
  });

  target.head.appendChild(tag);
  return tag;
};

const insertSheet = (tag: HTMLStyleElement, rule: string) => {
  const sheet = tag.sheet;

  if (sheet) {
    if (sheet.cssRules.length > 0) {
      sheet.deleteRule(0);
    }
    sheet.insertRule(rule, 0);
  } else if (process.env.NODE_ENV !== 'production') {
    // eslint-disable-next-line no-console
    console.error('FluentProvider: No sheet available on styleTag, styles will not be inserted into DOM.');
  }
};

/**
 * Writes a theme as css variables in a style tag on the provided targetDocument as a rule applied to a CSS class
 * @internal
 * @returns CSS class to apply the rule
 */
export const useFontFaceProviderStyleTag = (options: {
  ruleSet: string;
  rendererAttributes: Record<string, string>;
  targetDocument: Document | undefined;
}) => {
  const { targetDocument, ruleSet, rendererAttributes } = options;

  const styleTag = React.useRef<HTMLStyleElement | undefined | null>();

  const styleTagId = useId('fluent-font-face-');
  const styleElementAttributes = rendererAttributes;

  useHandleSSRStyleElements(targetDocument, styleTagId);
  useInsertionEffect(() => {
    // The style element could already have been created during SSR - no need to recreate it
    const ssrStyleElement = targetDocument?.getElementById(styleTagId);
    if (ssrStyleElement) {
      styleTag.current = ssrStyleElement as HTMLStyleElement;
    } else {
      styleTag.current = createStyleTag(targetDocument, { ...styleElementAttributes, id: styleTagId });
      if (styleTag.current) {
        insertSheet(styleTag.current, ruleSet);
      }
    }

    return () => {
      styleTag.current?.remove();
    };
  }, [styleTagId, targetDocument, ruleSet, styleElementAttributes]);

  return { styleTagId };
};

function useHandleSSRStyleElements(targetDocument: Document | undefined | null, styleTagId: string) {
  // Using a state factory so that this logic only runs once per render
  // Each FluentProvider can create its own style element during SSR as a slot
  // Moves all theme style elements to document head during render to avoid hydration errors.
  // Should be strict mode safe since the logic is idempotent.
  React.useState(() => {
    if (!targetDocument) {
      return;
    }

    const themeStyleElement = targetDocument.getElementById(styleTagId);
    if (themeStyleElement) {
      targetDocument.head.append(themeStyleElement);
    }
  });
}

// ---

export const FontFaceProvider: React.FC<{ children?: React.ReactNode }> = props => {
  const { targetDocument } = useFluent();
  const renderer = useRenderer_unstable();

  const ruleSet = React.useMemo(() => createDefaultFontFaces(DEFAULT_CDN_BASE_URL), []);

  useFontFaceProviderStyleTag({
    ruleSet,
    rendererAttributes: renderer.styleElementAttributes ?? {},
    targetDocument,
  });

  return <>{props.children}</>;
};
microsoft-github-policy-service[bot] commented 9 months ago

Because this issue has not had activity for over 150 days, we're automatically closing it for house-keeping purposes.

Still require assistance? Please, create a new issue with up-to date details.

daniel-rabe commented 5 months ago

is it allowed to use the fonts of cdn on commercial website?

and can we serve this fonts from our assets folder without using the cdn url?