@tcn/ui 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. package/dist/form/field/h_field/h_field.d.ts.map +1 -1
  2. package/dist/form/field/h_field/h_field.js +33 -35
  3. package/dist/form/field/h_field/h_field.js.map +1 -1
  4. package/dist/form/field/v_field/v_field.d.ts.map +1 -1
  5. package/dist/form/field/v_field/v_field.js +34 -36
  6. package/dist/form/field/v_field/v_field.js.map +1 -1
  7. package/dist/frame.css +1 -1
  8. package/dist/inputs/color_input/color_input.d.ts.map +1 -1
  9. package/dist/inputs/color_input/color_input.js +47 -46
  10. package/dist/inputs/color_input/color_input.js.map +1 -1
  11. package/dist/inputs/combo_box/combo_box.d.ts.map +1 -1
  12. package/dist/inputs/combo_box/combo_box.js +61 -58
  13. package/dist/inputs/combo_box/combo_box.js.map +1 -1
  14. package/dist/inputs/index.d.ts +1 -0
  15. package/dist/inputs/index.d.ts.map +1 -1
  16. package/dist/inputs/index.js +34 -31
  17. package/dist/inputs/index.js.map +1 -1
  18. package/dist/inputs/input/input.js +9 -9
  19. package/dist/inputs/input/input.js.map +1 -1
  20. package/dist/inputs/input_group/input_group.d.ts +5 -0
  21. package/dist/inputs/input_group/input_group.d.ts.map +1 -0
  22. package/dist/inputs/input_group/input_group.js +20 -0
  23. package/dist/inputs/input_group/input_group.js.map +1 -0
  24. package/dist/inputs/phone_number_input/countries_phone_information.d.ts +2 -2
  25. package/dist/inputs/phone_number_input/countries_phone_information.d.ts.map +1 -1
  26. package/dist/inputs/phone_number_input/countries_phone_information.js +5 -353
  27. package/dist/inputs/phone_number_input/countries_phone_information.js.map +1 -1
  28. package/dist/inputs/phone_number_input/phone_number_context.d.ts +24 -0
  29. package/dist/inputs/phone_number_input/phone_number_context.d.ts.map +1 -0
  30. package/dist/inputs/phone_number_input/phone_number_context.js +23 -0
  31. package/dist/inputs/phone_number_input/phone_number_context.js.map +1 -0
  32. package/dist/inputs/phone_number_input/phone_number_country_select_adapter.d.ts +19 -0
  33. package/dist/inputs/phone_number_input/phone_number_country_select_adapter.d.ts.map +1 -0
  34. package/dist/inputs/phone_number_input/phone_number_country_select_adapter.js +77 -0
  35. package/dist/inputs/phone_number_input/phone_number_country_select_adapter.js.map +1 -0
  36. package/dist/inputs/phone_number_input/phone_number_input.d.ts +16 -14
  37. package/dist/inputs/phone_number_input/phone_number_input.d.ts.map +1 -1
  38. package/dist/inputs/phone_number_input/phone_number_input.js +104 -274
  39. package/dist/inputs/phone_number_input/phone_number_input.js.map +1 -1
  40. package/dist/inputs/phone_number_input/phone_number_input_adapter.d.ts +6 -0
  41. package/dist/inputs/phone_number_input/phone_number_input_adapter.d.ts.map +1 -0
  42. package/dist/inputs/phone_number_input/phone_number_input_adapter.js +95 -0
  43. package/dist/inputs/phone_number_input/phone_number_input_adapter.js.map +1 -0
  44. package/dist/inputs/phone_number_input/sip_input.d.ts +12 -0
  45. package/dist/inputs/phone_number_input/sip_input.d.ts.map +1 -0
  46. package/dist/inputs/phone_number_input/sip_input.js +111 -0
  47. package/dist/inputs/phone_number_input/sip_input.js.map +1 -0
  48. package/dist/inputs/select/select.d.ts.map +1 -1
  49. package/dist/inputs/select/select.js +3 -2
  50. package/dist/inputs/select/select.js.map +1 -1
  51. package/dist/inputs/suggestions/suggestion_list.d.ts +4 -1
  52. package/dist/inputs/suggestions/suggestion_list.d.ts.map +1 -1
  53. package/dist/inputs/suggestions/suggestion_list.js +120 -111
  54. package/dist/inputs/suggestions/suggestion_list.js.map +1 -1
  55. package/dist/inputs/textarea/textarea.js +8 -8
  56. package/dist/inputs/textarea/textarea.js.map +1 -1
  57. package/dist/inputs/unit_input/unit_input.d.ts.map +1 -1
  58. package/dist/inputs/unit_input/unit_input.js +39 -39
  59. package/dist/inputs/unit_input/unit_input.js.map +1 -1
  60. package/dist/overlay/frame/frame.d.ts +8 -4
  61. package/dist/overlay/frame/frame.d.ts.map +1 -1
  62. package/dist/overlay/frame/frame.js +87 -23
  63. package/dist/overlay/frame/frame.js.map +1 -1
  64. package/dist/overlay/popper/base/dismissal_decorator.js.map +1 -1
  65. package/dist/overlay/popper/legacy/popper.d.ts.map +1 -1
  66. package/dist/overlay/popper/legacy/popper.js +52 -50
  67. package/dist/overlay/popper/legacy/popper.js.map +1 -1
  68. package/dist/phone_number_input.css +1 -1
  69. package/dist/stacks/box/bottom_resize_handle.d.ts +1 -1
  70. package/dist/stacks/box/bottom_resize_handle.d.ts.map +1 -1
  71. package/dist/stacks/box/bottom_resize_handle.js.map +1 -1
  72. package/dist/stacks/box/box.d.ts +2 -2
  73. package/dist/stacks/box/box.d.ts.map +1 -1
  74. package/dist/stacks/box/box.js.map +1 -1
  75. package/dist/stacks/box/end_resize_handle.d.ts +1 -1
  76. package/dist/stacks/box/end_resize_handle.d.ts.map +1 -1
  77. package/dist/stacks/box/end_resize_handle.js.map +1 -1
  78. package/dist/stacks/box/left_resize_handle.d.ts +1 -1
  79. package/dist/stacks/box/left_resize_handle.d.ts.map +1 -1
  80. package/dist/stacks/box/left_resize_handle.js.map +1 -1
  81. package/dist/stacks/box/resize_handlers.d.ts +2 -2
  82. package/dist/stacks/box/resize_handlers.d.ts.map +1 -1
  83. package/dist/stacks/box/resize_handlers.js +32 -32
  84. package/dist/stacks/box/resize_handlers.js.map +1 -1
  85. package/dist/stacks/box/right_resize_handle.d.ts +1 -1
  86. package/dist/stacks/box/right_resize_handle.d.ts.map +1 -1
  87. package/dist/stacks/box/right_resize_handle.js.map +1 -1
  88. package/dist/stacks/box/start_resize_handle.d.ts +1 -1
  89. package/dist/stacks/box/start_resize_handle.d.ts.map +1 -1
  90. package/dist/stacks/box/start_resize_handle.js +4 -4
  91. package/dist/stacks/box/start_resize_handle.js.map +1 -1
  92. package/dist/stacks/box/top_resize_handle.d.ts +1 -1
  93. package/dist/stacks/box/top_resize_handle.d.ts.map +1 -1
  94. package/dist/stacks/box/top_resize_handle.js +4 -4
  95. package/dist/stacks/box/top_resize_handle.js.map +1 -1
  96. package/dist/stacks/h_collapsible_box.js +18 -18
  97. package/dist/stacks/h_collapsible_box.js.map +1 -1
  98. package/dist/stacks/v_collapsible_box.js +18 -18
  99. package/dist/stacks/v_collapsible_box.js.map +1 -1
  100. package/dist/suggestion_list.css +1 -1
  101. package/dist/surfaces/window/window.d.ts +1 -1
  102. package/dist/surfaces/window/window.d.ts.map +1 -1
  103. package/dist/surfaces/window/window.js +20 -10
  104. package/dist/surfaces/window/window.js.map +1 -1
  105. package/dist/themes/stylesheets/reset.css +1 -1
  106. package/dist/themes/stylesheets/reset.js +8 -1
  107. package/dist/themes/stylesheets/reset.js.map +1 -1
  108. package/dist/themes/themes/ergo/ergo_theme.css +1 -1
  109. package/dist/themes/themes/ergo/ergo_theme.js +183 -18
  110. package/dist/themes/themes/ergo/ergo_theme.js.map +1 -1
  111. package/dist/typography/body_text/body_text.d.ts.map +1 -1
  112. package/dist/typography/body_text/body_text.js +12 -10
  113. package/dist/typography/body_text/body_text.js.map +1 -1
  114. package/dist/utils/dnd/hooks/use_drag_container.d.ts.map +1 -1
  115. package/dist/utils/dnd/hooks/use_drag_container.js +22 -19
  116. package/dist/utils/dnd/hooks/use_drag_container.js.map +1 -1
  117. package/package.json +4 -2
  118. package/src/form/field/h_field/h_field.tsx +0 -4
  119. package/src/form/field/v_field/v_field.stories.tsx +8 -0
  120. package/src/form/field/v_field/v_field.tsx +1 -4
  121. package/src/form/field_set/field_set.stories.tsx +2 -1
  122. package/src/inputs/__docs__/inputs.mdx +81 -0
  123. package/src/inputs/__docs__/inputs.stories.tsx +268 -0
  124. package/src/inputs/color_input/color_input.tsx +17 -17
  125. package/src/inputs/combo_box/combo_box.tsx +17 -13
  126. package/src/inputs/index.ts +2 -0
  127. package/src/inputs/input/input.tsx +1 -1
  128. package/src/inputs/input_group/input_group.tsx +26 -0
  129. package/src/inputs/phone_number_input/countries_phone_information.ts +6 -353
  130. package/src/inputs/phone_number_input/phone_number_context.tsx +32 -0
  131. package/src/inputs/phone_number_input/phone_number_country_select_adapter.tsx +126 -0
  132. package/src/inputs/phone_number_input/phone_number_input.module.css +5 -63
  133. package/src/inputs/phone_number_input/phone_number_input.stories.tsx +180 -150
  134. package/src/inputs/phone_number_input/phone_number_input.tsx +133 -400
  135. package/src/inputs/phone_number_input/phone_number_input_adapter.tsx +123 -0
  136. package/src/inputs/phone_number_input/sip_input.tsx +147 -0
  137. package/src/inputs/select/select.tsx +13 -14
  138. package/src/inputs/suggestions/suggestion_list.module.css +1 -0
  139. package/src/inputs/suggestions/suggestion_list.stories.tsx +12 -8
  140. package/src/inputs/suggestions/suggestion_list.tsx +24 -3
  141. package/src/inputs/textarea/textarea.tsx +1 -1
  142. package/src/inputs/unit_input/unit_input.tsx +17 -17
  143. package/src/overlay/frame/frame.module.css +2 -4
  144. package/src/overlay/frame/frame.stories.tsx +13 -10
  145. package/src/overlay/frame/frame.tsx +123 -15
  146. package/src/overlay/popper/base/dismissal_decorator.tsx +1 -1
  147. package/src/overlay/popper/legacy/popper.tsx +5 -1
  148. package/src/stacks/box/bottom_resize_handle.tsx +6 -1
  149. package/src/stacks/box/box.tsx +12 -2
  150. package/src/stacks/box/end_resize_handle.tsx +6 -1
  151. package/src/stacks/box/left_resize_handle.tsx +6 -1
  152. package/src/stacks/box/resize_handlers.ts +20 -8
  153. package/src/stacks/box/right_resize_handle.tsx +6 -1
  154. package/src/stacks/box/start_resize_handle.tsx +7 -2
  155. package/src/stacks/box/top_resize_handle.tsx +7 -2
  156. package/src/stacks/h_collapsible_box.tsx +2 -2
  157. package/src/stacks/v_collapsible_box.tsx +2 -2
  158. package/src/surfaces/window/window.tsx +14 -4
  159. package/src/themes/stories/controls_fieldset.tsx +1 -1
  160. package/src/themes/stylesheets/reset.css +8 -1
  161. package/src/themes/themes/ergo/ergo_theme.css +183 -18
  162. package/src/typography/body_text/body_text.tsx +2 -0
  163. package/src/utils/dnd/__stories__/draggable.stories.tsx +14 -8
  164. package/src/utils/dnd/hooks/use_drag_container.ts +13 -3
  165. package/src/inputs/phone_number_input/__tests__/utils.test.ts +0 -52
@@ -1,428 +1,161 @@
1
+ import React, { useLayoutEffect } from 'react';
2
+ import PhoneInput from 'react-phone-number-input';
3
+ import 'react-phone-number-input/style.css';
4
+ import type { CountryCode } from 'libphonenumber-js';
1
5
  import { HStack, type HStackProps } from '../../stacks/h_stack.js';
2
- import { clsx } from 'clsx';
3
- import React, {
4
- useCallback,
5
- useLayoutEffect,
6
- useMemo,
7
- useRef,
8
- useState,
9
- Children,
10
- isValidElement,
11
- } from 'react';
12
- import { SlimButton } from '../../actions/index.js';
13
- import { Select } from '../select/select.js';
14
- import { MaskInput } from '../mask_input/mask_input.js';
15
- import {
16
- CountryPhoneInformation,
17
- countriesPhoneInformation,
18
- } from './countries_phone_information.js';
19
6
  import styles from './phone_number_input.module.css';
20
- import { NotebookIcon } from '@tcn/icons/notebook_icon.js';
21
- import { Option, OptionProps } from '../options/option.js';
22
- import { SuggestionList } from '../suggestions/suggestion_list.js';
23
- import { stripNonNumericAfterCountryCode } from './utils.js';
24
- import { useForkRef } from '../../utils/index.js';
25
-
26
- const OBFUSCATED_CHARACTER = '';
27
-
28
- function createObfuscatedMasks(masks: { mask: string; placeholder?: string }[]) {
29
- return masks.map(m => ({
30
- ...m,
31
- placeholder: m.mask.replace(/[9a*]/g, OBFUSCATED_CHARACTER),
32
- }));
33
- }
34
-
35
- const countryList = countriesPhoneInformation.map(i => ({
36
- name: i.name,
37
- selectedLabel: i.prefix,
38
- optionLabel: `${i.prefix} (${i.code}) ${i.unicodeFlag}`,
39
- value: i.code,
40
- keywords: i.keywords,
41
- }));
42
-
43
- countryList.sort((a, b) => parseInt(a.value) - parseInt(b.value));
44
-
45
- const countryPrefixMap: Map<string, CountryPhoneInformation[]> = new Map();
46
- const countryCodeMap: Map<string, CountryPhoneInformation> = new Map();
47
- countriesPhoneInformation.forEach(i => {
48
- countryCodeMap.set(i.code, i);
49
- if (countryPrefixMap.has(i.prefix)) {
50
- countryPrefixMap.get(i.prefix)?.push(i);
51
- } else {
52
- countryPrefixMap.set(i.prefix, [i]);
53
- }
54
- });
55
-
56
- function createCountryOptions(allowedCountryCodes?: string[]) {
57
- if (allowedCountryCodes != null) {
58
- const allowedMap: Record<string, boolean> = {};
59
- allowedCountryCodes.forEach(c => {
60
- allowedMap[c] = true;
61
- });
62
- return countryList
63
- .filter(c => Boolean(allowedMap[c.value]))
64
- .map(i => {
65
- return (
66
- <Option
67
- key={i.value}
68
- value={i.value}
69
- label={i.selectedLabel}
70
- keywords={i.keywords}
71
- >
72
- {i.optionLabel}
73
- </Option>
74
- );
75
- });
76
- } else {
77
- return countryList.map(i => {
78
- return (
79
- <Option
80
- key={i.value}
81
- value={i.value}
82
- label={i.selectedLabel}
83
- keywords={i.keywords}
84
- >
85
- {i.optionLabel}
86
- </Option>
87
- );
88
- });
89
- }
90
- }
91
-
92
- function getCountryCodeFromValue(
93
- value: string,
94
- previousCountry: string,
95
- defaultCountry: string
96
- ) {
97
- for (let x = 5; x > 1; x--) {
98
- const prefix = value.slice(0, x);
99
- const countriesInformation = countryPrefixMap.get(prefix);
100
-
101
- if (countriesInformation != null) {
102
- const countryInformation = countriesInformation.find(
103
- c => c.code === previousCountry
104
- );
105
-
106
- if (countryInformation != null) {
107
- return countryInformation;
108
- } else {
109
- return countriesInformation[0];
110
- }
111
- }
112
- }
113
-
114
- return (countryCodeMap.get(defaultCountry.toUpperCase()) ||
115
- countryCodeMap.get('US')) as CountryPhoneInformation;
116
- }
7
+ import { PhoneBookProvider, type PhoneContext } from './phone_number_context.js';
8
+ import { PhoneNumberCountrySelectAdapter } from './phone_number_country_select_adapter.js';
9
+ import { type OptionProps, Option } from '../options/option.js';
10
+ import { PhoneNumberInputAdapter } from './phone_number_input_adapter.js';
11
+ import { clsx } from 'clsx';
12
+ import { SipInput } from './sip_input.js';
13
+ import { defaultCountries } from './countries_phone_information.js';
117
14
 
118
15
  export interface PhoneNumberInputProps
119
16
  extends Omit<HStackProps, 'onChange' | 'children'> {
120
17
  value?: string;
121
- name?: string;
122
- autoComplete?: string;
18
+ allowSip?: boolean;
19
+ onChange: (value?: string) => void;
123
20
  defaultCountry?: string;
124
- /**
125
- * Callback fired when the phone number value changes.
126
- * @param value - The phone number value with country prefix
127
- * @param obfuscate - Whether the selected phone number is obfuscated (e.g., from a phone book entry marked as obfuscated)
128
- */
129
- onChange?: (value: string, obfuscate: boolean) => void;
130
- countrySelectRef?: React.Ref<HTMLButtonElement>;
131
- phoneNumberInputRef?: React.Ref<HTMLInputElement>;
132
- disabled?: boolean;
133
- disabledPhoneNumber?: boolean;
134
21
  allowedCountryCodes?: string[];
22
+ disabled?: boolean;
23
+ name?: string;
24
+ 'aria-label'?: string;
25
+ autoFocus?: boolean;
26
+ placeholder?: string;
27
+ className?: string;
28
+ width?: string;
29
+ onCountryChange?: (country: CountryCode) => void;
30
+ limitMaxLength?: boolean;
31
+ ariaSelectLabel?: string;
32
+ ariaPhoneBookButtonLabel?: string;
135
33
  children?: React.ReactElement<OptionProps> | React.ReactElement<OptionProps>[];
136
34
  }
137
35
 
138
- export const PhoneNumberInput = React.forwardRef(function PhoneNumberInput(
139
- {
140
- value = '',
141
- name,
142
- autoComplete,
143
- defaultCountry = 'US',
144
- onChange,
145
- countrySelectRef: countryRef,
146
- phoneNumberInputRef: numberRef,
147
- disabled = false,
148
- disabledPhoneNumber = false,
149
- allowedCountryCodes,
150
- children,
151
- ...props
152
- }: PhoneNumberInputProps,
153
- ref: React.Ref<HTMLElement>
154
- ) {
155
- const lastOutputValueRef = useRef(value);
156
- const [phoneBookElement, setPhoneBookElement] = useState<HTMLButtonElement | null>(
157
- null
36
+ export function PhoneNumberInput({
37
+ value = '',
38
+ allowSip = true,
39
+ onChange,
40
+ defaultCountry = 'US',
41
+ allowedCountryCodes: countries,
42
+ disabled,
43
+ name,
44
+ 'aria-label': ariaLabel,
45
+ autoFocus,
46
+ placeholder,
47
+ onCountryChange,
48
+ limitMaxLength,
49
+ ariaSelectLabel,
50
+ ariaPhoneBookButtonLabel,
51
+ children,
52
+ ...props
53
+ }: PhoneNumberInputProps) {
54
+ const [focusNumberInput, setFocusNumberInput] = React.useState<boolean>(false);
55
+ const isSip = value?.toLocaleLowerCase().startsWith('sip:') || false;
56
+ const [cachedNumber, setCachedNumber] = React.useState<string>(isSip ? '' : value);
57
+ const [sipAddress, setSipAddress] = React.useState<string>(
58
+ isSip ? value?.substring(4).trim() : ''
158
59
  );
159
- const isPhoneBookOpen = phoneBookElement != null;
160
- const [selectedCountry, setSelectedCountry] = useState(defaultCountry);
161
- const countryInformation = getCountryCodeFromValue(
162
- value,
163
- selectedCountry,
164
- defaultCountry
60
+ const [country, setCountry] = React.useState<CountryCode | undefined>(
61
+ defaultCountry as CountryCode
165
62
  );
166
- const [phoneNumber, setPhoneNumber] = useState(() => {
167
- const phoneNumber = value.split(countryInformation.prefix)[1];
168
63
 
169
- return phoneNumber == null ? '' : stripNonNumericAfterCountryCode(phoneNumber);
170
- });
64
+ countries = countries || defaultCountries;
65
+
66
+ useLayoutEffect(() => {
67
+ if (isSip) {
68
+ setSipAddress(value?.substring(4).trim() || '');
69
+ }
70
+ }, [isSip, value]);
71
+
72
+ useLayoutEffect(() => {
73
+ if (focusNumberInput) {
74
+ setFocusNumberInput(false);
75
+ }
76
+ }, [focusNumberInput]);
171
77
 
172
78
  // Extract valid Option components from children
173
- const phoneBookOptions = useMemo(() => {
174
- return Children.toArray(children).filter(
79
+ const phoneBookOptions = React.useMemo(() => {
80
+ return React.Children.toArray(children).filter(
175
81
  (child): child is React.ReactElement<OptionProps> =>
176
- isValidElement(child) && child.type === Option
82
+ React.isValidElement(child) && child.type === Option
177
83
  );
178
84
  }, [children]);
179
85
 
180
- const showPhoneBook = phoneBookOptions.length > 0;
181
- const [countryCode, setCountryCode] = useState(countryInformation.code);
182
- const [currentMasks, setCurrentMasks] = useState([
183
- ...countriesPhoneInformation[0].masks,
86
+ const phoneContext = React.useMemo<PhoneContext>(() => {
87
+ return {
88
+ value,
89
+ allowSip,
90
+ phoneBook: phoneBookOptions,
91
+ setValue: onChange,
92
+ ariaPhoneBookButtonLabel,
93
+ ariaSelectLabel,
94
+ disabled,
95
+ setCountry,
96
+ sipAddress,
97
+ setSipAddress,
98
+ cachedNumber,
99
+ setCachedNumber,
100
+ focusNumberInput,
101
+ setFocusNumberInput,
102
+ };
103
+ }, [
104
+ value,
105
+ allowSip,
106
+ phoneBookOptions,
107
+ onChange,
108
+ ariaPhoneBookButtonLabel,
109
+ ariaSelectLabel,
110
+ disabled,
111
+ sipAddress,
112
+ cachedNumber,
113
+ focusNumberInput,
184
114
  ]);
185
- const [obfuscateValue, setObfuscateValue] = useState(false);
186
- const [shouldFocusAfterClear, setShouldFocusAfterClear] = useState(false);
187
- const internalInputRef = useRef<HTMLInputElement>(null);
188
- const forkedInputRef = useForkRef(numberRef, internalInputRef);
189
-
190
- const countryOptions = useMemo(() => {
191
- return createCountryOptions(allowedCountryCodes);
192
- }, [allowedCountryCodes]);
193
-
194
- function changeCountry(countryCodeValue: string) {
195
- const countryInformation = countryCodeMap.get(countryCodeValue);
196
-
197
- if (countryInformation == null) {
198
- return;
199
- }
200
-
201
- setCountryCode(countryInformation.code);
202
- setCurrentMasks([...countryInformation.masks]);
203
- setSelectedCountry(countryInformation.code);
204
115
 
205
- if (phoneNumber == null) {
206
- return;
207
- }
208
-
209
- const value = `${countryInformation.prefix}${stripNonNumericAfterCountryCode(phoneNumber)}`;
210
- lastOutputValueRef.current = value;
211
- onChange && onChange(value, false);
212
- }
213
-
214
- useLayoutEffect(() => {
215
- const countryInformation = getCountryCodeFromValue(
216
- value,
217
- selectedCountry,
218
- defaultCountry
116
+ if (isSip) {
117
+ return (
118
+ <PhoneBookProvider value={phoneContext}>
119
+ <SipInput
120
+ onChange={onChange}
121
+ disabled={disabled}
122
+ name={name}
123
+ aria-label={ariaLabel}
124
+ autoFocus={autoFocus}
125
+ placeholder={placeholder}
126
+ countries={countries as CountryCode[]}
127
+ />
128
+ </PhoneBookProvider>
219
129
  );
220
- setCountryCode(countryInformation.code);
221
- setCurrentMasks([...countryInformation.masks]);
222
- setSelectedCountry(countryInformation.code);
223
- }, [value, selectedCountry, defaultCountry]);
224
-
225
- function transformValue(newPhoneNumber: string) {
226
- const countryPrefix = countryCodeMap.get(countryCode)?.prefix;
227
- const lineNumber = stripNonNumericAfterCountryCode(newPhoneNumber);
228
- const outputValue = countryPrefix + lineNumber;
229
-
230
- // Clear obfuscated state when user types manually
231
- if (obfuscateValue) {
232
- setObfuscateValue(false);
233
- }
234
-
235
- lastOutputValueRef.current = outputValue;
236
- phoneNumber !== newPhoneNumber && setPhoneNumber(newPhoneNumber);
237
- onChange && onChange(outputValue, false);
238
- }
239
-
240
- function togglePhoneBook(e: React.MouseEvent<HTMLButtonElement>) {
241
- if (isPhoneBookOpen) {
242
- setPhoneBookElement(null);
243
- } else {
244
- setPhoneBookElement(e.currentTarget);
245
- }
246
- }
247
-
248
- function closePhoneBook() {
249
- setPhoneBookElement(null);
250
- }
251
-
252
- function handlePhoneBookOptionSelect(
253
- value: string,
254
- _label: string | undefined,
255
- _isSuggestion: boolean,
256
- obfuscate: boolean
257
- ) {
258
- // Update the phone number with the selected value
259
- setObfuscateValue(obfuscate);
260
- updatePhoneNumber(value, obfuscate);
261
- closePhoneBook();
262
- }
263
-
264
- function preparePasteValue(value: string) {
265
- if (value.startsWith('+')) {
266
- const countryInformation = getCountryCodeFromValue(
267
- value,
268
- selectedCountry,
269
- defaultCountry
270
- );
271
- setCountryCode(countryInformation.code);
272
- setCurrentMasks([...countryInformation.masks]);
273
-
274
- const phoneNumber = value.split(countryInformation.prefix)[1];
275
- setPhoneNumber(phoneNumber);
276
-
277
- return phoneNumber;
278
- }
279
-
280
- return value;
281
- }
282
-
283
- function handleObfuscatedInputChange(newValue: string) {
284
- // When user types on a obfuscated input, clear the obfuscated state and start fresh
285
- // The newValue will be the digits the user typed (mask filters to valid input)
286
- setShouldFocusAfterClear(true);
287
- setObfuscateValue(false);
288
- setPhoneNumber(newValue);
289
-
290
- const countryPrefix = countryCodeMap.get(countryCode)?.prefix;
291
- const lineNumber = stripNonNumericAfterCountryCode(newValue);
292
- const outputValue = countryPrefix + lineNumber;
293
- lastOutputValueRef.current = outputValue;
294
- onChange && onChange(outputValue, false);
295
130
  }
296
131
 
297
- const updatePhoneNumber = useCallback(
298
- (value: string, obfuscate = false) => {
299
- const oldValue = lastOutputValueRef.current;
300
- const countryInformation = getCountryCodeFromValue(
301
- value,
302
- selectedCountry,
303
- defaultCountry
304
- );
305
- const phoneNumber = value.split(countryInformation.prefix)[1];
306
- setCountryCode(countryInformation.code);
307
- setCurrentMasks([...countryInformation.masks]);
308
- setSelectedCountry(countryInformation.code);
309
-
310
- if (oldValue !== value) {
311
- setPhoneNumber(phoneNumber);
312
- onChange && onChange(value, obfuscate);
313
- }
314
- },
315
- [defaultCountry, selectedCountry, onChange]
316
- );
317
-
318
- useLayoutEffect(() => {
319
- updatePhoneNumber(value);
320
- }, [value, updatePhoneNumber]);
321
-
322
- // Focus the input after transitioning from obfuscated to normal mode
323
- useLayoutEffect(() => {
324
- if (shouldFocusAfterClear && !obfuscateValue && internalInputRef.current) {
325
- internalInputRef.current.focus();
326
- setShouldFocusAfterClear(false);
327
- }
328
- }, [shouldFocusAfterClear, obfuscateValue]);
329
-
330
132
  return (
331
- <HStack
332
- ref={ref}
333
- className={clsx(styles['phone-number-input'], 'tcn-phone-number-input')}
334
- height="auto"
335
- {...props}
336
- >
337
- <Select
338
- className={clsx(
339
- styles['phone-number-input-select'],
340
- 'tcn-phone-number-input-select'
341
- )}
342
- ref={countryRef}
343
- width="auto"
344
- value={obfuscateValue ? '' : countryCode}
345
- onChange={changeCountry}
346
- disabled={disabled || obfuscateValue || disabledPhoneNumber}
347
- data-is-disabled={disabled || obfuscateValue || disabledPhoneNumber}
348
- data-is-obfuscated={obfuscateValue}
349
- placeholder={obfuscateValue ? '––' : undefined}
350
- >
351
- {countryOptions}
352
- </Select>
353
- <HStack
354
- width="flex"
355
- className={clsx(
356
- styles['phone-number-input-container'],
357
- 'tcn-phone-number-input-container'
358
- )}
359
- >
360
- {obfuscateValue ? (
361
- <MaskInput
362
- key="obfuscated"
363
- name={name}
364
- autoComplete={autoComplete}
365
- ref={forkedInputRef}
366
- value=""
367
- mask={createObfuscatedMasks(currentMasks)}
368
- onChange={handleObfuscatedInputChange}
369
- disabled={disabled || disabledPhoneNumber}
370
- data-is-disabled={disabled || disabledPhoneNumber}
371
- data-has-phone-book={showPhoneBook}
372
- data-is-obfuscated={true}
373
- className={clsx(
374
- styles['phone-number-input'],
375
- styles['phone-number-input-obfuscated'],
376
- 'tcn-phone-number-input'
377
- )}
378
- preparePasteValue={() => ''}
379
- prepareCopyValue={() => ''}
380
- prepareCutValue={() => ''}
381
- />
382
- ) : (
383
- <MaskInput
384
- key="normal"
385
- name={name}
386
- autoComplete={autoComplete}
387
- ref={forkedInputRef}
388
- value={phoneNumber}
389
- mask={currentMasks}
390
- onChange={transformValue}
391
- disabled={disabled || disabledPhoneNumber}
392
- data-is-disabled={disabled || disabledPhoneNumber}
393
- data-has-phone-book={showPhoneBook}
394
- data-is-obfuscated={false}
395
- className={clsx(styles['phone-number-input'], 'tcn-phone-number-input')}
396
- preparePasteValue={preparePasteValue}
397
- />
398
- )}
399
- </HStack>
400
- {showPhoneBook && (
401
- <>
402
- <SlimButton
403
- disabled={disabled}
404
- className={clsx(
405
- styles['phone-number-input-phone-book'],
406
- 'tcn-phone-number-input-phone-book'
407
- )}
408
- onClick={togglePhoneBook}
409
- size="md"
410
- >
411
- <NotebookIcon size="md" />
412
- </SlimButton>
413
-
414
- {isPhoneBookOpen && (
415
- <SuggestionList
416
- anchorElement={phoneBookElement}
417
- onOptionSelect={handlePhoneBookOptionSelect}
418
- onClose={closePhoneBook}
419
- noSuggestionMessage="No phone numbers found"
420
- >
421
- {phoneBookOptions}
422
- </SuggestionList>
133
+ <PhoneBookProvider value={phoneContext}>
134
+ <HStack {...props}>
135
+ <PhoneInput
136
+ value={value}
137
+ onChange={onChange}
138
+ defaultCountry={country}
139
+ country={country}
140
+ countries={countries as CountryCode[]}
141
+ countrySelectComponent={PhoneNumberCountrySelectAdapter}
142
+ inputComponent={PhoneNumberInputAdapter}
143
+ disabled={disabled}
144
+ name={name}
145
+ aria-label={ariaLabel}
146
+ autoFocus={autoFocus}
147
+ placeholder={placeholder}
148
+ onCountryChange={onCountryChange}
149
+ limitMaxLength={limitMaxLength}
150
+ className={clsx(
151
+ styles['phone-number-input'],
152
+ 'tcn-phone-number-input',
153
+ 'tcn-input-group'
423
154
  )}
424
- </>
425
- )}
426
- </HStack>
155
+ displayInitialValueAsLocalNumber
156
+ addInternationalOption={false}
157
+ />
158
+ </HStack>
159
+ </PhoneBookProvider>
427
160
  );
428
- });
161
+ }
@@ -0,0 +1,123 @@
1
+ import { NotebookIcon } from '@tcn/icons/notebook_icon.js';
2
+ import clsx from 'clsx';
3
+ import { forwardRef, useState, useCallback, useLayoutEffect, useRef } from 'react';
4
+ import { Button } from '../../actions/index.js';
5
+ import { SuggestionList } from '../suggestions/suggestion_list.js';
6
+ import { usePhoneContext } from './phone_number_context.js';
7
+ import { getCountryFromValue } from './phone_number_country_select_adapter.js';
8
+ import { Input } from '../input/input.js';
9
+ import { useForkRef } from '../../utils/hooks/use_fork_ref.js';
10
+
11
+ /**
12
+ * Bridges `@tcn/ui/inputs` Input (onChange: (value, event?) => void)
13
+ * with react-phone-number-input's expectation (onChange: (event) => void).
14
+ */
15
+ export const PhoneNumberInputAdapter = forwardRef<
16
+ HTMLInputElement,
17
+ React.InputHTMLAttributes<HTMLInputElement>
18
+ >(function InputAdapter({ onChange, value = '', className, ...rest }, ref) {
19
+ value = value.toString();
20
+ const [phoneBookElement, setPhoneBookElement] = useState<HTMLButtonElement | null>(
21
+ null
22
+ );
23
+ const isPhoneBookOpen = phoneBookElement != null;
24
+ const internalInputRef = useRef<HTMLInputElement | null>(null);
25
+ const forkedRef = useForkRef(ref, internalInputRef);
26
+
27
+ const {
28
+ phoneBook: phoneBookOptions,
29
+ setValue,
30
+ ariaPhoneBookButtonLabel,
31
+ disabled,
32
+ setFocusNumberInput,
33
+ focusNumberInput,
34
+ } = usePhoneContext();
35
+
36
+ const showPhoneBook = phoneBookOptions.length > 0;
37
+
38
+ function togglePhoneBook(e: React.MouseEvent<HTMLButtonElement>) {
39
+ if (isPhoneBookOpen) {
40
+ setPhoneBookElement(null);
41
+ } else {
42
+ setPhoneBookElement(e.currentTarget);
43
+ }
44
+ }
45
+
46
+ function closePhoneBook() {
47
+ setPhoneBookElement(null);
48
+ }
49
+
50
+ function handlePhoneBookOptionSelect(value: string) {
51
+ closePhoneBook();
52
+ setFocusNumberInput(true);
53
+ setValue(value);
54
+ }
55
+
56
+ const handleChange = useCallback(
57
+ (value: string, event?: React.ChangeEvent<HTMLInputElement>) => {
58
+ if (!onChange) return;
59
+ const e =
60
+ event ??
61
+ ({
62
+ target: { value },
63
+ currentTarget: { value },
64
+ } as React.ChangeEvent<HTMLInputElement>);
65
+ onChange(e);
66
+ },
67
+ [onChange]
68
+ );
69
+
70
+ if (value.startsWith('+')) {
71
+ const country = getCountryFromValue(value);
72
+ const prefixLength = country?.prefix?.length || 0;
73
+ value = value.substring(prefixLength);
74
+ }
75
+
76
+ useLayoutEffect(() => {
77
+ const input = internalInputRef?.current;
78
+
79
+ if (input == null || !focusNumberInput) {
80
+ return;
81
+ }
82
+
83
+ requestAnimationFrame(() => {
84
+ if (input.value.length > 0) {
85
+ input.select();
86
+ } else {
87
+ input.focus();
88
+ }
89
+ });
90
+ }, [focusNumberInput]);
91
+
92
+ return (
93
+ <>
94
+ <Input
95
+ ref={forkedRef}
96
+ value={value}
97
+ {...(rest as any)}
98
+ className={clsx(className, 'tcn-input-group-slot')}
99
+ onChange={handleChange}
100
+ />
101
+ {showPhoneBook && (
102
+ <Button
103
+ disabled={disabled}
104
+ className={clsx('tcn-input-group-slot', 'tcn-phone-number-phone-book')}
105
+ aria-label={ariaPhoneBookButtonLabel}
106
+ onClick={togglePhoneBook}
107
+ size="md"
108
+ >
109
+ <NotebookIcon size="md" />
110
+ </Button>
111
+ )}
112
+ <SuggestionList
113
+ open={isPhoneBookOpen}
114
+ anchorElement={phoneBookElement}
115
+ onOptionSelect={handlePhoneBookOptionSelect}
116
+ onClose={closePhoneBook}
117
+ noSuggestionMessage="No phone numbers found"
118
+ >
119
+ {phoneBookOptions}
120
+ </SuggestionList>
121
+ </>
122
+ );
123
+ });