QuiiBz / next-international

Type-safe internationalization (i18n) for Next.js
https://next-international.vercel.app
MIT License
1.25k stars 59 forks source link

Add documentation for `<html>` tag `dir` attribute #220

Closed gustaveWPM closed 10 months ago

gustaveWPM commented 11 months ago

Hello there!

I think it would be a good idea to introduce the concept of "Direction" regarding the dir attribute of the <html> tag on the pages, in order to adjust the reading direction according to the app's current language.

However, I don't think it's necessary to integrate this feature directly into Next International. It seems to me that third-party libraries could handle this adequately.

Personally, I think it would be a good idea to document this and provide a solution. No more, no less.

QuiiBz commented 11 months ago

Great idea, I agree that we could add a documentation about this. It should be pretty straitghtforward to implement manually.

RemyJouni commented 11 months ago

Great idea, I agree that we could add a documentation about this. It should be pretty straitghtforward to implement manually.

Would you please share the code implementation here, I'm really in need of it right now.

gustaveWPM commented 11 months ago

Would you please share the code implementation here, I'm really in need of it right now.

Ping @RemyJouni

I know that i18next integrates this feature. So... Maybe something like this?

(I simply extract the parts of the code that seem most relevant to analyze here. I think there are a lot of safety checks and additional features that could be avoided.)

export function getCleanedCode(code) {
  if (code && code.indexOf('_') > 0) return code.replace('_', '-');
  return code;
}
// * ... Too lazy to investigate about the "options" object, sorry

export function formatLanguageCode(code, options = {}) {
  // http://www.iana.org/assignments/language-tags/language-tags.xhtml
  if (typeof code === 'string' && code.indexOf('-') > -1) {
    const specialCases = ['hans', 'hant', 'latn', 'cyrl', 'cans', 'mong', 'arab'];
    let p = code.split('-');

    if (options.lowerCaseLng) {
      p = p.map((part) => part.toLowerCase());
    } else if (p.length === 2) {
      p[0] = p[0].toLowerCase();
      p[1] = p[1].toUpperCase();

      if (specialCases.indexOf(p[1].toLowerCase()) > -1) p[1] = capitalize(p[1].toLowerCase());
    } else if (p.length === 3) {
      p[0] = p[0].toLowerCase();

      // if length 2 guess it's a country
      if (p[1].length === 2) p[1] = p[1].toUpperCase();
      if (p[0] !== 'sgn' && p[2].length === 2) p[2] = p[2].toUpperCase();

      if (specialCases.indexOf(p[1].toLowerCase()) > -1) p[1] = capitalize(p[1].toLowerCase());
      if (specialCases.indexOf(p[2].toLowerCase()) > -1) p[2] = capitalize(p[2].toLowerCase());
    }

    return p.join('-');
  }

  return options.cleanCode || options.lowerCaseLng ? code.toLowerCase() : code;
}
export function getLanguagePartFromCode(code) {
  code = getCleanedCode(code);
  if (!code || code.indexOf('-') < 0) return code;

  const p = code.split('-');
  return formatLanguageCode(p[0]);
}
export function dir(lng) {
  const rtlLngs = [
      'ar',
      'shu',
      'sqr',
      'ssh',
      'xaa',
      'yhd',
      'yud',
      'aao',
      'abh',
      'abv',
      'acm',
      'acq',
      'acw',
      'acx',
      'acy',
      'adf',
      'ads',
      'aeb',
      'aec',
      'afb',
      'ajp',
      'apc',
      'apd',
      'arb',
      'arq',
      'ars',
      'ary',
      'arz',
      'auz',
      'avl',
      'ayh',
      'ayl',
      'ayn',
      'ayp',
      'bbz',
      'pga',
      'he',
      'iw',
      'ps',
      'pbt',
      'pbu',
      'pst',
      'prp',
      'prd',
      'ug',
      'ur',
      'ydd',
      'yds',
      'yih',
      'ji',
      'yi',
      'hbo',
      'men',
      'xmn',
      'fa',
      'jpr',
      'peo',
      'pes',
      'prs',
      'dv',
      'sam',
      'ckb'
    ];

  return rtlLngs.indexOf(getLanguagePartFromCode(lng)) > -1 || lng.toLowerCase().indexOf('-arab') > 1
    ? 'rtl'
    : 'ltr';
}

Then:

// * ... import dir from '...'

const PagesHtmlElement: FunctionComponent<HtmlElementProps> = ({ children, params }) => {
  const locale = params.locale;
  return (
    <html lang={locale} dir={dir(locale)}>
      <body>{children}</body>
    </html>
)

See also: https://github.com/i18next/i18next/blob/master/src/i18next.js#L507 (the dir function is at line 507)

gustaveWPM commented 11 months ago

Next Intl have implemented this with an another approach, using a hook.

You could take inspiration from it.


There are also NPM libs for this purpose.

Like this one (idk if it fits your needs, I haven't tested any of those libs yet): rtl-detect

QuiiBz commented 11 months ago

We could also use Intl.Locale.prototype.getTextInfo, that is built-in in Node.js and most browsers (except Firefox, somehow): https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Locale/getTextInfo

It returns an object with a direction property that is either ltr or rtl:

// In the root layout
export default function Layout({ children, params: { locale } }: { children: ReactElement, params: { locale: string } }) => {
  const dir = new Intl.Locale(locale).getTextInfo().direction

  return (
    <html lang={locale} dir={dir}>
      <body>
        {children}
      </body>
    </html>
)

(note that this doesn't work in Firefox, if your root layout is a client component)

gustaveWPM commented 10 months ago

Hi @QuiiBz I currently encounter some edge cases with dir="rtl" / dir="ltr".

I would like to know: is there a onLocaleChange event/hook?

Maybe I could make a new issue to request this feature, but I prefer to primarily ask it here since it is related to this issue too.

EDIT: huuummm, maybe I could just call useCurrentLocale. I'm dumb. Sorry! Gonna try it.

gustaveWPM commented 10 months ago

'k. onLocaleChange is pointless. Here is a minimalist example of what I've done:

// LanguageSwitcher.tsx
'use client';

import { useChangeLocale, useCurrentLocale } from '@/i18n/client';
import { FunctionComponent, useEffect } from 'react';

interface LanguageSwitcherProps {}

const LanguageSwitcher: FunctionComponent<LanguageSwitcherProps> = () => {
  const currentLocale = useCurrentLocale();
  const changeLocale = useChangeLocale();

  useEffect(() => {
    if (currentLocale === 'some-rtl-locale') document.documentElement.dir = 'rtl';
    else document.documentElement.dir = 'ltr';
  }, [currentLocale]);

  return (
    <div className="flex flex-col gap-4">
      <button onClick={() => changeLocale('en')}>EN</Button>
      <button onClick={() => changeLocale('fr')}>FR</Button>
    </div>
  );
};
export default LanguageSwitcher;
QuiiBz commented 10 months ago

Looks great. Do you want to send a PR to add https://github.com/QuiiBz/next-international/issues/220#issuecomment-1749140518 and your useEffect above to the documentation?

gustaveWPM commented 10 months ago

Looks great. Do you want to send a PR to add #220 (comment) and your useEffect above to the documentation?

It would be nice if I could do a little PR for the occasion! :) I might take a bit of time though, I don't think I'll be able to do it before the weekend.

gustaveWPM commented 10 months ago

Hello @QuiiBz

I found a way to make work the solution you described in https://github.com/QuiiBz/next-international/issues/220#issuecomment-1749140518

Even on Firefox, even on client component.

Using this polyfill: https://github.com/brettz9/intl-locale-textinfo-polyfill

Like this:

import Locale from 'intl-locale-textinfo-polyfill';
const { direction: dir } = new Locale(locale).textInfo;

  // * ...
  return (
    <html lang={locale} dir={dir}>
    // * ...

However, there is a small issue with this polyfill, which requires an ugly workaround for now: https://github.com/brettz9/intl-locale-textinfo-polyfill/issues/1

Although there's a need to write this rather ugly Typescript definition file, I think it's interesting to use this polyfill, especially as its developer seems rather active: this issue should be solved within a reasonable time.


I'm thinking of using this as a basis for RTL/LTR handling documentation (with the polyfill). I'll create the PR tomorrow.

QuiiBz commented 10 months ago

Looks great, thanks for the investigation!

gustaveWPM commented 10 months ago

Okay, I think the PR is done: https://github.com/QuiiBz/next-international/pull/273

I'm looking forward to read your review. :) (I'm somehow a messy person, and I need to proofread a lot before I'm satisfied with my work. Sorry for spamming.)

gustaveWPM commented 10 months ago

The author of 'intl-locale-textinfo-polyfill' just fixed the ponyfill import issue https://github.com/brettz9/intl-locale-textinfo-polyfill/issues/1#issuecomment-1784211024

We don't need the ugly Typescript definition file workaround to use it anymore.

gustaveWPM commented 10 months ago

Oops... I don't see the documentation page, nor on mobile, nor on desktop.

QuiiBz commented 10 months ago

Indeed, the page is present (https://next-international.vercel.app/docs/rtl-support) but not shown in the sidebar, even though it works locally.

Redeployed without the build cache and it's now showing as expected.

gustaveWPM commented 10 months ago

Awesome! Glad to have contributed a little