Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions src/main/resources/assets/admin/common/js/form2/LocaleContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type {ReactNode} from 'react';
import {createContext, useContext} from 'react';

const LocaleContext = createContext<string | undefined>(undefined);

export type LocaleProviderProps = {
locale: string | undefined;
children?: ReactNode;
};

const LOCALE_PROVIDER_NAME = 'LocaleProvider';

export const LocaleProvider = ({locale, children}: LocaleProviderProps): ReactNode => {
return <LocaleContext.Provider value={locale}>{children}</LocaleContext.Provider>;
};

LocaleProvider.displayName = LOCALE_PROVIDER_NAME;

export const useLocale = (): string | undefined => useContext(LocaleContext);
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {OccurrencesBuilder} from '../../../form/Occurrences';
import type {TextAreaConfig} from '../../descriptor';
import {FieldRegistry, generateProcessingToken, type ProcessingToken} from '../../FieldRegistry';
import {FieldRegistryProvider} from '../../FieldRegistryContext';
import {LocaleProvider} from '../../LocaleContext';
import type {InputTypeComponentProps} from '../../types';
import {TextAreaInput, type TextAreaInputProps} from './TextAreaInput';

Expand Down Expand Up @@ -193,6 +194,40 @@ export const Highlight: Story = {
render: () => <HighlightDemo />,
};

export const WithLocale: Story = {
name: 'Features / Locale (lang)',
render: () => (
<LocaleProvider locale='nb-NO'>
<div className='flex flex-col gap-y-3 p-4'>
<div className='max-w-120 text-sm text-subtle'>
Wrapped in <code>LocaleProvider locale="nb-NO"</code>. The rendered textarea gets{' '}
<code>lang="nb"</code> and <code>spellcheck="true"</code> so the browser uses the Norwegian
dictionary.
</div>
<TextAreaInput
{...defaultArgs}
value={ValueTypes.STRING.newValue('Hei verden!\nDette er et tekstområde.')}
/>
</div>
</LocaleProvider>
),
};

export const WithRtlLocale: Story = {
name: 'Features / Locale (RTL)',
render: () => (
<LocaleProvider locale='ar-SA'>
<div className='flex flex-col gap-y-3 p-4'>
<div className='max-w-120 text-sm text-subtle'>
Wrapped in <code>LocaleProvider locale="ar-SA"</code>. The textarea renders right-to-left with{' '}
<code>lang="ar" dir="rtl"</code>.
</div>
<TextAreaInput {...defaultArgs} value={ValueTypes.STRING.newValue('مرحبا بالعالم\nهذه منطقة نصية.')} />
</div>
</LocaleProvider>
),
};

const STORY_PATH = '.story.field';
const OCCURRENCE_ID = 'occurrence-0';

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ import {useEffect, useRef} from 'react';

import {ValueTypes} from '../../../data/ValueTypes';
import type {TextAreaConfig} from '../../descriptor';
import {useLocale} from '../../LocaleContext';
import type {InputTypeComponentProps} from '../../types';
import {getFirstError, getInputAccessibleName} from '../../utils';
import {getFirstError, getInputAccessibleName, getLangAttributes} from '../../utils';
import {Counter} from '../counter';

export type TextAreaInputProps = InputTypeComponentProps<TextAreaConfig>;
Expand All @@ -31,6 +32,8 @@ export const TextAreaInput = ({
// ? Scroll is owned by the parent InputField (gated on RevealOptions.scroll);
// the inner blink should highlight only, never scroll again.
const isBlinking = useBlinkAttention(textAreaRef, highlight, {scrollIntoView: false});
const locale = useLocale();
const langAttrs = getLangAttributes(locale);

useEffect(() => {
if (externalInputRef == null) return undefined;
Expand Down Expand Up @@ -63,6 +66,7 @@ export const TextAreaInput = ({
return (
<TextArea
ref={textAreaRef}
{...langAttrs}
aria-label={getInputAccessibleName(input, index)}
autoSize
value={stringValue}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {OccurrencesBuilder} from '../../../form/Occurrences';
import type {TextLineConfig} from '../../descriptor';
import {FieldRegistry, generateProcessingToken, type ProcessingToken} from '../../FieldRegistry';
import {FieldRegistryProvider} from '../../FieldRegistryContext';
import {LocaleProvider} from '../../LocaleContext';
import type {InputTypeComponentProps} from '../../types';
import {TextLineInput, type TextLineInputProps} from './TextLineInput';

Expand Down Expand Up @@ -186,6 +187,37 @@ export const Highlight: Story = {
render: () => <HighlightDemo />,
};

export const WithLocale: Story = {
name: 'Features / Locale (lang)',
render: () => (
<LocaleProvider locale='nb-NO'>
<div className='flex flex-col gap-y-3 p-4'>
<div className='max-w-120 text-sm text-subtle'>
Wrapped in <code>LocaleProvider locale="nb-NO"</code>. The rendered input gets{' '}
<code>lang="nb"</code> and <code>spellcheck="true"</code> so the browser uses the Norwegian
dictionary.
</div>
<TextLineInput {...defaultArgs} value={ValueTypes.STRING.newValue('Hei verden')} />
</div>
</LocaleProvider>
),
};

export const WithRtlLocale: Story = {
name: 'Features / Locale (RTL)',
render: () => (
<LocaleProvider locale='ar-SA'>
<div className='flex flex-col gap-y-3 p-4'>
<div className='max-w-120 text-sm text-subtle'>
Wrapped in <code>LocaleProvider locale="ar-SA"</code>. The input is rendered right-to-left with{' '}
<code>lang="ar" dir="rtl"</code>.
</div>
<TextLineInput {...defaultArgs} value={ValueTypes.STRING.newValue('مرحبا بالعالم')} />
</div>
</LocaleProvider>
),
};

const STORY_PATH = '.story.field';
const OCCURRENCE_ID = 'occurrence-0';

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const mocks = vi.hoisted(() => ({
]),
useEffect: vi.fn(),
useRef: vi.fn((initial: unknown) => ({current: initial})),
useContext: vi.fn(() => undefined),
input: vi.fn(() => null),
}));

Expand All @@ -27,6 +28,7 @@ vi.mock('react', async importOriginal => {
useState: mocks.useState,
useEffect: mocks.useEffect,
useRef: mocks.useRef,
useContext: mocks.useContext,
};
});

Expand Down Expand Up @@ -97,6 +99,7 @@ describe('TextLineInput', () => {
]);
mocks.useEffect.mockImplementation(() => undefined);
mocks.useRef.mockImplementation((initial: unknown) => ({current: initial}));
mocks.useContext.mockImplementation(() => undefined);
});

describe('value transformation', () => {
Expand Down Expand Up @@ -146,4 +149,34 @@ describe('TextLineInput', () => {
expect(onChange.mock.calls[0][1]).toBe('abc');
});
});

describe('locale attributes', () => {
it('defaults to spellCheck only when no locale is provided', () => {
const element = TextLineInput(makeProps()) as VNode;

expect(element.props.spellCheck).toBe(true);
expect(element.props.lang).toBeUndefined();
expect(element.props.dir).toBeUndefined();
});

it('emits lang for a non-RTL locale', () => {
mocks.useContext.mockReturnValue('nb-NO');

const element = TextLineInput(makeProps()) as VNode;

expect(element.props.lang).toBe('nb');
expect(element.props.spellCheck).toBe(true);
expect(element.props.dir).toBeUndefined();
});

it('emits lang and dir=rtl for an RTL locale', () => {
mocks.useContext.mockReturnValue('ar-SA');

const element = TextLineInput(makeProps()) as VNode;

expect(element.props.lang).toBe('ar');
expect(element.props.dir).toBe('rtl');
expect(element.props.spellCheck).toBe(true);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ import {useEffect, useRef, useState} from 'react';

import {ValueTypes} from '../../../data/ValueTypes';
import type {TextLineConfig} from '../../descriptor';
import {useLocale} from '../../LocaleContext';
import type {InputTypeComponentProps} from '../../types';
import {getFirstError, getInputAccessibleName} from '../../utils';
import {getFirstError, getInputAccessibleName, getLangAttributes} from '../../utils';
import {Counter} from '../counter';

export type TextLineInputProps = InputTypeComponentProps<TextLineConfig>;
Expand Down Expand Up @@ -37,6 +38,8 @@ export const TextLineInput = ({
// ? Scroll is owned by the parent InputField (gated on RevealOptions.scroll);
// the inner blink should highlight only, never scroll again.
const isBlinking = useBlinkAttention(inputRef, highlight, {scrollIntoView: false});
const locale = useLocale();
const langAttrs = getLangAttributes(locale);
const hasMaxLength = config.maxLength > 0;
const maxLength = hasMaxLength ? config.maxLength : undefined;
const hasBoth = hasMaxLength && config.showCounter;
Expand Down Expand Up @@ -79,6 +82,7 @@ export const TextLineInput = ({
return (
<Input
ref={inputRef}
{...langAttrs}
aria-label={getInputAccessibleName(input, index)}
value={rawInput}
onChange={handleChange}
Expand Down
1 change: 1 addition & 0 deletions src/main/resources/assets/admin/common/js/form2/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export {type UsePropertySetArrayResult, usePropertySetArray} from './hooks/usePr
export {type UseSetOccurrenceManagerResult, useSetOccurrenceManager} from './hooks/useSetOccurrenceManager';
export {I18nProvider, useI18n} from './I18nContext';
export {initBuiltInTypes} from './initBuiltInTypes';
export {LocaleProvider, type LocaleProviderProps, useLocale} from './LocaleContext';
// Raw value tracking
export {RawValueProvider, type RawValueProviderProps, useRawValueMap} from './RawValueContext';
// React input type system (for CS and future consumers)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import {describe, expect, it} from 'vitest';
import {getLangAttributes} from './getLangAttributes';

describe('getLangAttributes', () => {
describe('absent locale', () => {
it('returns spellCheck only when locale is undefined', () => {
expect(getLangAttributes(undefined)).toEqual({spellCheck: true});
});

it('returns spellCheck only when locale is empty string', () => {
expect(getLangAttributes('')).toEqual({spellCheck: true});
});
});

describe('language extraction', () => {
it('extracts language from a region-qualified locale', () => {
expect(getLangAttributes('en-US')).toEqual({lang: 'en', spellCheck: true});
});

it('passes a bare language code through', () => {
expect(getLangAttributes('nb')).toEqual({lang: 'nb', spellCheck: true});
});

it('lowercases mixed-case input', () => {
expect(getLangAttributes('NB-NO')).toEqual({lang: 'nb', spellCheck: true});
});
});

describe('RTL detection', () => {
it('adds dir=rtl for Arabic', () => {
expect(getLangAttributes('ar-SA')).toEqual({lang: 'ar', dir: 'rtl', spellCheck: true});
});

it('adds dir=rtl for Hebrew', () => {
expect(getLangAttributes('he')).toEqual({lang: 'he', dir: 'rtl', spellCheck: true});
});

it('adds dir=rtl for Persian with region subtag', () => {
expect(getLangAttributes('fa-IR')).toEqual({lang: 'fa', dir: 'rtl', spellCheck: true});
});

it('adds dir=rtl when the script subtag is Arabic', () => {
expect(getLangAttributes('ks-Arab')).toEqual({lang: 'ks', dir: 'rtl', spellCheck: true});
});

it('omits dir for non-RTL languages', () => {
const result = getLangAttributes('de-DE');
expect(result).toEqual({lang: 'de', spellCheck: true});
expect(result.dir).toBeUndefined();
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
const RTL_LANGUAGE_CODES: ReadonlySet<string> = new Set([
'ar',
'dv',
'fa',
'ha',
'he',
'ks',
'ku',
'ps',
'sd',
'ur',
'yi',
]);

const ARABIC_SCRIPT_SUBTAG = 'arab';

export type LangAttributes = {
lang?: string;
dir?: 'rtl';
spellCheck: true;
};

function isRtl(locale: string, language: string): boolean {
if (RTL_LANGUAGE_CODES.has(language)) return true;
return locale.split('-')[1]?.toLowerCase() === ARABIC_SCRIPT_SUBTAG;
}

export function getLangAttributes(locale: string | undefined): LangAttributes {
if (locale == null || locale.length === 0) return {spellCheck: true};

const normalized = locale.toLowerCase();
const language = normalized.split('-')[0];
const attrs: LangAttributes = {lang: language, spellCheck: true};

if (isRtl(normalized, language)) attrs.dir = 'rtl';

return attrs;
}
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './accessibility';
export * from './getLangAttributes';
export * from './validation';