mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
Merge remote-tracking branch 'origin/WorkflowChange' into feature/exam-attempt-domain
This commit is contained in:
@@ -21,6 +21,7 @@ export * from "./lib/layout/LanguageSwitcher";
|
||||
export * from "./lib/layout/PageHeader";
|
||||
export * from "./lib/input/PasswordRequirements";
|
||||
export * from "./lib/input/CountrySelect";
|
||||
export * from "./lib/input/PhoneInput";
|
||||
export * from "./lib/input/phone";
|
||||
export * from "./lib/data/AdvancedTable";
|
||||
export * from "./lib/feedback/use-error-handler";
|
||||
|
||||
@@ -16,9 +16,9 @@ registerLocale(en);
|
||||
registerLocale(am);
|
||||
registerNationalityLocale(nationalityEn);
|
||||
|
||||
type CountryLang = 'en' | 'am';
|
||||
export type CountryLang = 'en' | 'am';
|
||||
|
||||
function resolveLang(lng: string): CountryLang {
|
||||
export function resolveLang(lng: string): CountryLang {
|
||||
return lng === 'am' ? 'am' : 'en';
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ export function getNationalityName(code: string | null | undefined, lang: Countr
|
||||
return getCountryName(code, lang);
|
||||
}
|
||||
|
||||
function CountryFlag({ code }: { code: string }) {
|
||||
export function CountryFlag({ code }: { code: string }) {
|
||||
const Flag = Flags[code as keyof typeof Flags];
|
||||
return Flag ? <Flag style={{ width: 22, borderRadius: 2, display: 'block' }} /> : null;
|
||||
}
|
||||
|
||||
219
libs/ui/src/lib/input/PhoneInput.tsx
Normal file
219
libs/ui/src/lib/input/PhoneInput.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Group, Select, Text, TextInput, type ComboboxItem, type SelectProps } from '@mantine/core';
|
||||
import {
|
||||
getCountries,
|
||||
getCountryCallingCode,
|
||||
parsePhoneNumberFromString,
|
||||
type CountryCode,
|
||||
} from 'libphonenumber-js';
|
||||
import { CountryFlag, getCountryName, resolveLang } from './CountrySelect';
|
||||
import { exceedsMaxLength, formatNational, maxNationalLength, nextNationalDigits, toE164, toNationalDigits } from './phone';
|
||||
|
||||
// Static list, computed once at module load — same as CountrySelect's dataset.
|
||||
const COUNTRY_CODES = getCountries();
|
||||
|
||||
type CountryOption = ComboboxItem & { name: string };
|
||||
|
||||
// Search by country name ("united"), dial code ("+1") or ISO prefix ("us") —
|
||||
// the closed control's label alone ("+1") isn't enough to find a country.
|
||||
const filterCountries: SelectProps['filter'] = ({ options, search }) => {
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) return options;
|
||||
return (options as CountryOption[]).filter(
|
||||
(o) =>
|
||||
o.name.toLowerCase().includes(q) ||
|
||||
o.label.toLowerCase().includes(q) ||
|
||||
o.value.toLowerCase().startsWith(q),
|
||||
);
|
||||
};
|
||||
|
||||
const renderCountryOption: SelectProps['renderOption'] = ({ option }) => {
|
||||
const o = option as CountryOption;
|
||||
return (
|
||||
<Group gap="xs" wrap="nowrap" justify="space-between" flex={1}>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<CountryFlag code={o.value} />
|
||||
<Text fz="sm">{o.name}</Text>
|
||||
</Group>
|
||||
<Text fz="xs" c="dimmed">{o.label}</Text>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
export interface PhoneInputProps {
|
||||
/** E.164 (`+14155552671`), or '' when empty. */
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
/**
|
||||
* Fired when focus leaves a field that has digits in it — wire to the
|
||||
* form's `trigger`. A blank field is left to submit-time validation, like
|
||||
* the form's other inputs, so tabbing past it doesn't raise an error.
|
||||
*/
|
||||
onBlur?: () => void;
|
||||
label?: React.ReactNode;
|
||||
placeholder?: string;
|
||||
description?: React.ReactNode;
|
||||
error?: React.ReactNode;
|
||||
required?: boolean;
|
||||
/** Mantine's asterisk-without-`required` variant, as used by config-driven forms. */
|
||||
withAsterisk?: boolean;
|
||||
disabled?: boolean;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* International phone entry: a searchable country/dial-code select beside a
|
||||
* national-number text box, WhatsApp/Telegram style. Controlled —
|
||||
* `value`/`onChange` carry the E.164 string.
|
||||
*
|
||||
* The typed digits live in local state rather than being re-derived from
|
||||
* `value` on every render: an incomplete number doesn't parse, so deriving
|
||||
* would blank the box between keystrokes.
|
||||
*/
|
||||
export function PhoneInput({
|
||||
value,
|
||||
onChange,
|
||||
onBlur,
|
||||
label,
|
||||
placeholder,
|
||||
description,
|
||||
error,
|
||||
required,
|
||||
withAsterisk,
|
||||
disabled,
|
||||
readOnly,
|
||||
}: PhoneInputProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = resolveLang(i18n.language);
|
||||
|
||||
const initialCountry = parsePhoneNumberFromString(value || '')?.country ?? 'ET';
|
||||
const [country, setCountry] = useState<CountryCode>(initialCountry);
|
||||
const [national, setNational] = useState(() =>
|
||||
formatNational(toNationalDigits(value, initialCountry), initialCountry),
|
||||
);
|
||||
// Mantine keeps the selected label ("+251") as the search text, so typing
|
||||
// would search for "+251u". Cleared while the dropdown is open instead.
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
// What this field last pushed upward, so an echo of our own value isn't
|
||||
// mistaken for the parent resetting the form.
|
||||
const emitted = useRef(value);
|
||||
|
||||
useEffect(() => {
|
||||
if (value === emitted.current) return;
|
||||
const parsedCountry = parsePhoneNumberFromString(value || '')?.country;
|
||||
const next = parsedCountry ?? country;
|
||||
if (parsedCountry) setCountry(parsedCountry);
|
||||
setNational(formatNational(toNationalDigits(value, next), next));
|
||||
emitted.current = value;
|
||||
// Adopting an outside change only — `country` is state written here, so
|
||||
// re-running on it would fight the user's typing.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [value]);
|
||||
|
||||
const countryOptions = useMemo<CountryOption[]>(
|
||||
() =>
|
||||
COUNTRY_CODES.map((code) => ({
|
||||
value: code,
|
||||
label: `+${getCountryCallingCode(code)}`,
|
||||
name: getCountryName(code, lang),
|
||||
})).sort((a, b) => a.name.localeCompare(b.name, lang)),
|
||||
[lang],
|
||||
);
|
||||
|
||||
function push(next: string) {
|
||||
emitted.current = next;
|
||||
onChange(next);
|
||||
}
|
||||
|
||||
function applyDigits(digits: string, forCountry: CountryCode) {
|
||||
// Keystroke past the longest possible number is ignored, not stored.
|
||||
if (exceedsMaxLength(digits, forCountry)) return;
|
||||
// Once the number is valid, show the true national part: someone who
|
||||
// types a trunk prefix (0911111111) or the country code (251911111111)
|
||||
// shouldn't end up with it doubled beside the "+251" selector. Not
|
||||
// before: a partial number can parse too, and rewriting it mid-typing
|
||||
// makes digits vanish under the caret.
|
||||
const parsed = parsePhoneNumberFromString(digits, forCountry);
|
||||
setNational(formatNational(parsed?.isValid() ? parsed.nationalNumber : digits, forCountry));
|
||||
push(toE164(digits, forCountry));
|
||||
}
|
||||
|
||||
function handleText(text: string) {
|
||||
// A full `+<code><number>` arriving at once (paste, autofill, or a test
|
||||
// driver's `.fill()`) is parsed standalone and switches the country,
|
||||
// rather than being read under whatever country was already selected.
|
||||
const trimmed = text.trim();
|
||||
if (trimmed.startsWith('+')) {
|
||||
const parsed = parsePhoneNumberFromString(trimmed);
|
||||
if (parsed?.country) {
|
||||
setCountry(parsed.country);
|
||||
setNational(formatNational(parsed.nationalNumber, parsed.country));
|
||||
push(parsed.number);
|
||||
return;
|
||||
}
|
||||
}
|
||||
applyDigits(nextNationalDigits(text, national), country);
|
||||
}
|
||||
|
||||
function handleCountryChange(next: string | null) {
|
||||
if (!next) return;
|
||||
const code = next as CountryCode;
|
||||
setCountry(code);
|
||||
// A number carried over from a longer plan is cut to fit the new one.
|
||||
applyDigits(national.replace(/\D/g, '').slice(0, maxNationalLength(code)), code);
|
||||
}
|
||||
|
||||
return (
|
||||
<TextInput
|
||||
type="tel"
|
||||
inputMode="tel"
|
||||
autoComplete="tel"
|
||||
label={label}
|
||||
description={description}
|
||||
error={error}
|
||||
required={required}
|
||||
withAsterisk={withAsterisk}
|
||||
disabled={disabled}
|
||||
readOnly={readOnly}
|
||||
placeholder={placeholder}
|
||||
value={national}
|
||||
onChange={(e) => handleText(e.currentTarget.value)}
|
||||
// Blur of the whole field, not of the number box: moving into the
|
||||
// country select mustn't validate a half-typed number.
|
||||
wrapperProps={{
|
||||
onBlur: (e: React.FocusEvent<HTMLDivElement>) => {
|
||||
if (national && !e.currentTarget.contains(e.relatedTarget)) onBlur?.();
|
||||
},
|
||||
}}
|
||||
leftSectionWidth={92}
|
||||
leftSectionPointerEvents={disabled || readOnly ? 'none' : 'all'}
|
||||
leftSection={
|
||||
<Select
|
||||
aria-label={t('phone.countryCode', 'Country code')}
|
||||
data={countryOptions}
|
||||
value={country}
|
||||
onChange={handleCountryChange}
|
||||
renderOption={renderCountryOption}
|
||||
filter={filterCountries}
|
||||
searchable
|
||||
searchValue={search}
|
||||
onSearchChange={setSearch}
|
||||
onDropdownOpen={() => setSearch('')}
|
||||
onDropdownClose={() => setSearch(`+${getCountryCallingCode(country)}`)}
|
||||
nothingFoundMessage={t('phone.noCountry', 'No matching country')}
|
||||
allowDeselect={false}
|
||||
disabled={disabled || readOnly}
|
||||
variant="unstyled"
|
||||
size="xs"
|
||||
w={92}
|
||||
maxDropdownHeight={320}
|
||||
comboboxProps={{ width: 260, position: 'bottom-start' }}
|
||||
leftSection={<CountryFlag code={country} />}
|
||||
leftSectionWidth={30}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
131
libs/ui/src/lib/input/phone.spec.ts
Normal file
131
libs/ui/src/lib/input/phone.spec.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { exceedsMaxLength, formatNational, maxNationalLength, nextNationalDigits, optionalPhoneNumber, phoneNumber, toE164, toNationalDigits } from './phone';
|
||||
import { AsYouType, parsePhoneNumberFromString } from 'libphonenumber-js';
|
||||
|
||||
describe('phoneNumber', () => {
|
||||
it('normalizes a legacy Ethiopian national number to E.164', () => {
|
||||
expect(phoneNumber.parse('0911223344')).toBe('+251911223344');
|
||||
});
|
||||
|
||||
it('passes an Ethiopian E.164 number through unchanged', () => {
|
||||
expect(phoneNumber.parse('+251911223344')).toBe('+251911223344');
|
||||
});
|
||||
|
||||
it('accepts a valid international number', () => {
|
||||
expect(phoneNumber.parse('+14155552671')).toBe('+14155552671');
|
||||
});
|
||||
|
||||
it('rejects a too-short number', () => {
|
||||
expect(() => phoneNumber.parse('+251911')).toThrow();
|
||||
});
|
||||
|
||||
it('rejects non-numeric input', () => {
|
||||
expect(() => phoneNumber.parse('abc')).toThrow('Enter a valid phone number');
|
||||
});
|
||||
|
||||
it('reports a blank value as missing, not invalid', () => {
|
||||
expect(() => phoneNumber.parse('')).toThrow('Phone number is required');
|
||||
});
|
||||
});
|
||||
|
||||
describe('optionalPhoneNumber', () => {
|
||||
it('allows a blank value', () => {
|
||||
expect(optionalPhoneNumber.parse('')).toBe('');
|
||||
});
|
||||
|
||||
it('still validates a non-blank value', () => {
|
||||
expect(() => optionalPhoneNumber.parse('abc')).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('typing helpers', () => {
|
||||
// Regression: an incomplete number doesn't parse, and an earlier version
|
||||
// collapsed it to '' — the box emptied on every keystroke.
|
||||
it('keeps partial digits as the number is typed one character at a time', () => {
|
||||
let display = '';
|
||||
let value = '';
|
||||
for (const ch of '911223344') {
|
||||
const digits = nextNationalDigits(display + ch, display);
|
||||
display = new AsYouType('ET').input(digits);
|
||||
value = toE164(digits, 'ET');
|
||||
}
|
||||
expect(display).toBe('911223344');
|
||||
expect(value).toBe('+251911223344');
|
||||
});
|
||||
|
||||
it('drops a digit when a keystroke only removed a formatting character', () => {
|
||||
// "(415)" backspaced to "(415" leaves the digits unchanged.
|
||||
expect(nextNationalDigits('(415', '(415)')).toBe('41');
|
||||
});
|
||||
|
||||
it('keeps the deleted digit count when a real digit is removed', () => {
|
||||
expect(nextNationalDigits('91122334', '911223344')).toBe('91122334');
|
||||
});
|
||||
|
||||
it('reads the national part back out of a stored E.164 value', () => {
|
||||
expect(toNationalDigits('+251911223344', 'ET')).toBe('911223344');
|
||||
expect(toNationalDigits('+14155552671', 'US')).toBe('4155552671');
|
||||
expect(toNationalDigits('', 'ET')).toBe('');
|
||||
});
|
||||
|
||||
it('falls back to a dial-code concatenation while the number is incomplete', () => {
|
||||
expect(toE164('9', 'ET')).toBe('+2519');
|
||||
expect(toE164('', 'ET')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('trunk prefix and country code entered into the number box', () => {
|
||||
// The box holds the national part next to a "+251" selector, so a trunk 0
|
||||
// or a typed country code must be absorbed, not shown (and doubled) there.
|
||||
const cases: Array<[string, string]> = [
|
||||
['0911111111', '911111111'],
|
||||
['251911111111', '911111111'],
|
||||
['911111111', '911111111'],
|
||||
];
|
||||
it.each(cases)('normalizes %s to the national number %s', (typed, national) => {
|
||||
const parsed = parsePhoneNumberFromString(typed.replace(/\D/g, ''), 'ET');
|
||||
expect(parsed?.nationalNumber).toBe(national);
|
||||
});
|
||||
|
||||
it.each(cases)('yields a valid E.164 value for %s', (typed) => {
|
||||
expect(phoneNumber.parse(typed)).toBe('+251911111111');
|
||||
});
|
||||
|
||||
it('accepts a pasted +251 number', () => {
|
||||
expect(phoneNumber.parse('+251911666666')).toBe('+251911666666');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatNational', () => {
|
||||
it('groups digits without the dial code or trunk prefix', () => {
|
||||
expect(formatNational('911223344', 'ET')).toBe('91 122 3344');
|
||||
expect(formatNational('4155552671', 'US')).toBe('415 555 2671');
|
||||
expect(formatNational('91', 'ET')).toBe('91');
|
||||
expect(formatNational('', 'ET')).toBe('');
|
||||
});
|
||||
|
||||
it('keeps digits intact when the number does not format', () => {
|
||||
expect(formatNational('0911223344', 'ET').replace(/\D/g, '')).toBe('0911223344');
|
||||
});
|
||||
});
|
||||
|
||||
describe('legacy stored values', () => {
|
||||
it('shows a national record without the trunk prefix', () => {
|
||||
expect(toNationalDigits('0911223344', 'ET')).toBe('911223344');
|
||||
});
|
||||
});
|
||||
|
||||
describe('length limit', () => {
|
||||
it('reports the longest national number per country', () => {
|
||||
expect(maxNationalLength('ET')).toBe(9);
|
||||
expect(maxNationalLength('US')).toBe(10);
|
||||
});
|
||||
|
||||
it('flags digits past the limit, counting the national part only', () => {
|
||||
expect(exceedsMaxLength('911223344', 'ET')).toBe(false);
|
||||
expect(exceedsMaxLength('9112233445', 'ET')).toBe(true);
|
||||
expect(exceedsMaxLength('0911223344', 'ET')).toBe(false);
|
||||
expect(exceedsMaxLength('251911223344', 'ET')).toBe(false);
|
||||
expect(exceedsMaxLength('09112233445', 'ET')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,88 @@
|
||||
import { z } from 'zod';
|
||||
import { AsYouType, Metadata, getCountryCallingCode, isValidPhoneNumber, parsePhoneNumberFromString, type CountryCode } from 'libphonenumber-js';
|
||||
|
||||
/** Accepts `09xxxxxxxx` or `+2519xxxxxxxx`; always yields `+2519xxxxxxxx`. */
|
||||
export const ethiopianPhone = z
|
||||
/**
|
||||
* Accepts any international number in E.164 (`+<country><number>`) or a
|
||||
* bare national number, which is assumed Ethiopian (`0911223344` ->
|
||||
* `+251911223344`) for backward compatibility with existing records.
|
||||
*/
|
||||
export const phoneNumber = z
|
||||
.string()
|
||||
.trim()
|
||||
.transform((v) => (/^09\d{8}$/.test(v) ? `+251${v.slice(1)}` : v))
|
||||
.refine((v) => /^\+2519\d{8}$/.test(v), {
|
||||
message: 'Enter a valid phone number (+2519xxxxxxxx)',
|
||||
.transform((v) => parsePhoneNumberFromString(v, 'ET')?.number ?? v)
|
||||
.superRefine((v, ctx) => {
|
||||
if (!v) ctx.addIssue({ code: 'custom', message: 'Phone number is required' });
|
||||
else if (!isValidPhoneNumber(v)) ctx.addIssue({ code: 'custom', message: 'Enter a valid phone number' });
|
||||
});
|
||||
|
||||
/** Same rules, but blank is allowed. */
|
||||
export const optionalEthiopianPhone = z.union([z.literal(''), ethiopianPhone]).optional();
|
||||
export const optionalPhoneNumber = z.union([z.literal(''), phoneNumber]).optional();
|
||||
|
||||
/**
|
||||
* Digits the field should hold after an edit. A keystroke that only removed
|
||||
* a formatting character (backspacing the ')' out of "(415)") leaves the
|
||||
* digits unchanged, which would otherwise make the caret stick — drop a real
|
||||
* digit in that case.
|
||||
*/
|
||||
export function nextNationalDigits(text: string, prevDisplay: string): string {
|
||||
const digits = text.replace(/\D/g, '');
|
||||
const deleting = text.length < prevDisplay.length;
|
||||
if (deleting && digits === prevDisplay.replace(/\D/g, '')) return digits.slice(0, -1);
|
||||
return digits;
|
||||
}
|
||||
|
||||
/** National digits of a stored value, for display in the number box. */
|
||||
export function toNationalDigits(value: string, country: CountryCode): string {
|
||||
if (!value) return '';
|
||||
// `country` also resolves legacy national records ("0911223344").
|
||||
const parsed = parsePhoneNumberFromString(value, country);
|
||||
if (parsed) return parsed.nationalNumber;
|
||||
if (value.startsWith('+')) {
|
||||
const prefix = `+${getCountryCallingCode(country)}`;
|
||||
if (value.startsWith(prefix)) return value.slice(prefix.length).replace(/\D/g, '');
|
||||
}
|
||||
return value.replace(/\D/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* E.164 for the digits typed so far. Incomplete numbers don't parse, so they
|
||||
* fall back to a plain dial-code concatenation rather than collapsing to ''
|
||||
* — the value has to survive mid-typing for the field to be usable.
|
||||
*/
|
||||
export function toE164(digits: string, country: CountryCode): string {
|
||||
if (!digits) return '';
|
||||
return (
|
||||
parsePhoneNumberFromString(digits, country)?.number ??
|
||||
`+${getCountryCallingCode(country)}${digits}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Digits grouped for display ("91 122 3344"). Formatted as an international
|
||||
* number with the dial code cut off: AsYouType's national mode leaves the
|
||||
* number ungrouped unless the trunk prefix was typed.
|
||||
*/
|
||||
export function formatNational(digits: string, country: CountryCode): string {
|
||||
if (!digits) return '';
|
||||
const prefix = `+${getCountryCallingCode(country)}`;
|
||||
const formatted = new AsYouType().input(prefix + digits);
|
||||
return formatted.startsWith(prefix) ? formatted.slice(prefix.length).trimStart() : digits;
|
||||
}
|
||||
|
||||
const metadata = new Metadata();
|
||||
|
||||
/** Longest national number the country's numbering plan allows. */
|
||||
export function maxNationalLength(country: CountryCode): number {
|
||||
metadata.selectNumberingPlan(country);
|
||||
return Math.max(...(metadata.numberingPlan?.possibleLengths() ?? [15]));
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the typed digits exceed the country's longest number. Judged on
|
||||
* the national part once it parses, so a trunk prefix (0911223344) or typed
|
||||
* country code (251911223344) isn't counted against the limit.
|
||||
*/
|
||||
export function exceedsMaxLength(digits: string, country: CountryCode): boolean {
|
||||
const national = parsePhoneNumberFromString(digits, country)?.nationalNumber ?? digits;
|
||||
return national.length > maxNationalLength(country);
|
||||
}
|
||||
|
||||
11
libs/ui/vite.config.mts
Normal file
11
libs/ui/vite.config.mts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
watch: false,
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
include: ['src/**/*.spec.ts'],
|
||||
reporters: ['default'],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user