@tui-cruises/mein-schiff-web-react-component-library 3.1.12 → 3.1.14
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.
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
|
|
4
4
|
|
|
5
|
+
### [3.1.14](https://bitbucket.org/yours_truly/tuic-mein-schiff-web-react-component-library/compare/v3.1.13...v3.1.14) (2026-04-15)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
### Features
|
|
9
|
+
|
|
10
|
+
* **BirthdateField:** add controlled value, minYear/maxYear, ISO-8601 support ([ba03b4a](https://bitbucket.org/yours_truly/tuic-mein-schiff-web-react-component-library/commit/ba03b4a443fdfbcb90a7dd24efacdd7106545827))
|
|
11
|
+
* **PhoneNumberInput:** add controlled value prop ([c33f049](https://bitbucket.org/yours_truly/tuic-mein-schiff-web-react-component-library/commit/c33f049febe5ffe0304951b85a1953ecb4b5d409))
|
|
12
|
+
|
|
13
|
+
### [3.1.13](https://bitbucket.org/yours_truly/tuic-mein-schiff-web-react-component-library/compare/v3.1.12...v3.1.13) (2026-04-15)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
### Features
|
|
17
|
+
|
|
18
|
+
* **PhoneNumberInput:** add disabled support and align muted look with InputField ([9b4ba3d](https://bitbucket.org/yours_truly/tuic-mein-schiff-web-react-component-library/commit/9b4ba3d4617eff7e382b32b18a15a97d7479f10d))
|
|
19
|
+
|
|
5
20
|
### [3.1.12](https://bitbucket.org/yours_truly/tuic-mein-schiff-web-react-component-library/compare/v3.1.11...v3.1.12) (2026-04-01)
|
|
6
21
|
|
|
7
22
|
|
package/package.json
CHANGED
|
@@ -42,10 +42,17 @@ export type BirthdateFieldProps = InputAttributes & {
|
|
|
42
42
|
className?: string;
|
|
43
43
|
locale?: string;
|
|
44
44
|
onChange?: (value: string) => void;
|
|
45
|
-
|
|
45
|
+
/** Uncontrolled initial value (YYYY-MM-DD). Ignored if `value` is provided. */
|
|
46
|
+
defaultValue?: string;
|
|
47
|
+
/** Controlled value (YYYY-MM-DD). When provided, internal state syncs to this prop. */
|
|
48
|
+
value?: string;
|
|
46
49
|
readOnly?: Boolean;
|
|
47
50
|
disabled?: Boolean;
|
|
48
51
|
legend?: string;
|
|
52
|
+
/** Minimum allowed year. Defaults to `currentYear - 100` (birthday mode). */
|
|
53
|
+
minYear?: number;
|
|
54
|
+
/** Maximum allowed year. Defaults to `currentYear` (birthday mode). */
|
|
55
|
+
maxYear?: number;
|
|
49
56
|
};
|
|
50
57
|
|
|
51
58
|
type SetPartFunction = {
|
|
@@ -99,7 +106,9 @@ const createCombinedValue = (day: string, month: string, year: string) => {
|
|
|
99
106
|
* @returns {object} An object containing day, month and year as strings.
|
|
100
107
|
*/
|
|
101
108
|
const parseInitialValue = (value: string) => {
|
|
102
|
-
|
|
109
|
+
// Strip time portion from ISO 8601 strings (e.g. "2020-01-05T00:00:00Z" → "2020-01-05")
|
|
110
|
+
const dateOnly = value.split('T')[0] ?? '';
|
|
111
|
+
const parts = dateOnly.split('-');
|
|
103
112
|
return {
|
|
104
113
|
year: normalizeInitialValueSubPart(parts[0] ?? ''),
|
|
105
114
|
month: normalizeInitialValueSubPart(parts[1] ?? ''),
|
|
@@ -135,16 +144,21 @@ const BirthdateField = forwardRef<HTMLInputElement, BirthdateFieldProps>(
|
|
|
135
144
|
locale = 'de-DE',
|
|
136
145
|
onChange,
|
|
137
146
|
defaultValue,
|
|
147
|
+
value,
|
|
138
148
|
readOnly,
|
|
139
149
|
disabled,
|
|
140
150
|
legend,
|
|
151
|
+
minYear,
|
|
152
|
+
maxYear,
|
|
141
153
|
...props
|
|
142
154
|
},
|
|
143
155
|
ref,
|
|
144
156
|
) => {
|
|
145
157
|
const format = getFormatBasedOnLocale(locale);
|
|
146
|
-
|
|
147
|
-
|
|
158
|
+
// When `value` is provided the component is controlled; otherwise we
|
|
159
|
+
// seed internal state from `defaultValue`.
|
|
160
|
+
const initialValueAsString = (value ?? defaultValue)?.toString() ?? '';
|
|
161
|
+
const initials = parseInitialValue(initialValueAsString);
|
|
148
162
|
const [day, setDay] = useState(initials.day);
|
|
149
163
|
const [month, setMonth] = useState(initials.month);
|
|
150
164
|
const [year, setYear] = useState(initials.year);
|
|
@@ -152,7 +166,11 @@ const BirthdateField = forwardRef<HTMLInputElement, BirthdateFieldProps>(
|
|
|
152
166
|
createCombinedValue(initials.day, initials.month, initials.year),
|
|
153
167
|
);
|
|
154
168
|
|
|
155
|
-
|
|
169
|
+
// Tracks the last combined value we either emitted via onChange or
|
|
170
|
+
// received via the `value` prop — used to avoid firing onChange in
|
|
171
|
+
// response to our own externally-echoed value, and to detect when
|
|
172
|
+
// an external `value` change needs to be synced into internal state.
|
|
173
|
+
const prevValue = useRef(combinedValue);
|
|
156
174
|
const dayRef = useRef<HTMLInputElement>(null);
|
|
157
175
|
const monthRef = useRef<HTMLInputElement>(null);
|
|
158
176
|
const yearRef = useRef<HTMLInputElement>(null);
|
|
@@ -175,6 +193,21 @@ const BirthdateField = forwardRef<HTMLInputElement, BirthdateFieldProps>(
|
|
|
175
193
|
}
|
|
176
194
|
}, [day, month, year, onChange]);
|
|
177
195
|
|
|
196
|
+
// Controlled mode: when the `value` prop changes from outside (i.e.
|
|
197
|
+
// differs from what we last emitted) re-seed internal state from it.
|
|
198
|
+
// Comparing against `prevValue` avoids a feedback loop when the parent
|
|
199
|
+
// echoes our own onChange value back as `value`.
|
|
200
|
+
useEffect(() => {
|
|
201
|
+
if (value === undefined) return;
|
|
202
|
+
const next = value.toString();
|
|
203
|
+
if (next === prevValue.current) return;
|
|
204
|
+
const parts = parseInitialValue(next);
|
|
205
|
+
prevValue.current = createCombinedValue(parts.day, parts.month, parts.year);
|
|
206
|
+
setDay(parts.day);
|
|
207
|
+
setMonth(parts.month);
|
|
208
|
+
setYear(parts.year);
|
|
209
|
+
}, [value]);
|
|
210
|
+
|
|
178
211
|
const handleChange = (
|
|
179
212
|
part: PartKey,
|
|
180
213
|
value: string,
|
|
@@ -232,10 +265,14 @@ const BirthdateField = forwardRef<HTMLInputElement, BirthdateFieldProps>(
|
|
|
232
265
|
nextRef,
|
|
233
266
|
)
|
|
234
267
|
}
|
|
235
|
-
min={
|
|
268
|
+
min={
|
|
269
|
+
part === 'year'
|
|
270
|
+
? (minYear ?? new Date().getFullYear() - 100) // Oldest passenger age: 100
|
|
271
|
+
: 1
|
|
272
|
+
}
|
|
236
273
|
max={
|
|
237
274
|
part === 'year'
|
|
238
|
-
? new Date().getFullYear()
|
|
275
|
+
? (maxYear ?? new Date().getFullYear())
|
|
239
276
|
: part === 'month'
|
|
240
277
|
? 12
|
|
241
278
|
: 31
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
InputHTMLAttributes,
|
|
5
|
+
useEffect,
|
|
6
|
+
useRef,
|
|
7
|
+
useState,
|
|
8
|
+
useId,
|
|
9
|
+
ReactNode,
|
|
10
|
+
} from 'react';
|
|
4
11
|
import { Icon } from '../../core/Icon';
|
|
5
12
|
import * as Form from '@radix-ui/react-form';
|
|
6
13
|
import * as SelectPrimitive from '@radix-ui/react-select';
|
|
@@ -15,8 +22,12 @@ import {
|
|
|
15
22
|
} from '../../../libs/phone-number/types';
|
|
16
23
|
import { CountryCode, parsePhoneNumber } from 'libphonenumber-js';
|
|
17
24
|
import { InputFieldError } from '../InputField/InputFieldError';
|
|
25
|
+
import { twMerge } from 'tailwind-merge';
|
|
18
26
|
|
|
19
|
-
type PhoneNumberInputProps =
|
|
27
|
+
type PhoneNumberInputProps = Omit<
|
|
28
|
+
InputHTMLAttributes<HTMLInputElement>,
|
|
29
|
+
'value'
|
|
30
|
+
> & {
|
|
20
31
|
countries?: PhoneNumberCountry[];
|
|
21
32
|
defaultCountryCode?: string;
|
|
22
33
|
invalid?: boolean;
|
|
@@ -24,6 +35,12 @@ type PhoneNumberInputProps = InputHTMLAttributes<HTMLInputElement> & {
|
|
|
24
35
|
error?: string;
|
|
25
36
|
ariaLabelCountryCode?: string;
|
|
26
37
|
ariaLabelPhoneNumber?: string;
|
|
38
|
+
/**
|
|
39
|
+
* Controlled value as a combined string (e.g. `"+49 1234567"`). When
|
|
40
|
+
* provided, internal state syncs to this prop. Use together with
|
|
41
|
+
* `onValueChange` for full controlled behavior.
|
|
42
|
+
*/
|
|
43
|
+
value?: string;
|
|
27
44
|
/** Called whenever the value changes with the dial code and number separately. */
|
|
28
45
|
onValueChange?: (value: { dialCode: string; localNumber: string }) => void;
|
|
29
46
|
};
|
|
@@ -87,19 +104,23 @@ const PhoneNumberInputRenderFunction: ForwardRefRenderFunction<
|
|
|
87
104
|
ariaLabelPhoneNumber = 'Phone number',
|
|
88
105
|
onValueChange,
|
|
89
106
|
readOnly,
|
|
107
|
+
disabled,
|
|
108
|
+
defaultValue,
|
|
109
|
+
value,
|
|
90
110
|
...attrs
|
|
91
111
|
} = props;
|
|
92
112
|
|
|
113
|
+
// When `value` is provided the component is controlled; otherwise we
|
|
114
|
+
// seed internal state from `defaultValue`.
|
|
115
|
+
const initialValueAsString = (value ?? defaultValue)?.toString() ?? '';
|
|
116
|
+
|
|
93
117
|
const defaultCountry = getDefaultCountry(
|
|
94
118
|
defaultCountryCode,
|
|
95
|
-
|
|
119
|
+
initialValueAsString,
|
|
96
120
|
);
|
|
97
121
|
|
|
98
122
|
const [typedInNumber, setTypedInNumber] = useState(
|
|
99
|
-
|
|
100
|
-
?.toString()
|
|
101
|
-
.substring(defaultCountry.dialCode.length)
|
|
102
|
-
.trim() || '',
|
|
123
|
+
initialValueAsString.substring(defaultCountry.dialCode.length).trim() || '',
|
|
103
124
|
);
|
|
104
125
|
|
|
105
126
|
const [selectedCountryCode, setSelectedCountryCode] = useState<string>(
|
|
@@ -110,18 +131,42 @@ const PhoneNumberInputRenderFunction: ForwardRefRenderFunction<
|
|
|
110
131
|
countries.find(country => country.code === selectedCountryCode)?.dialCode ||
|
|
111
132
|
'';
|
|
112
133
|
|
|
134
|
+
// Tracks the last combined `${dialCode} ${localNumber}`.trim() value we
|
|
135
|
+
// either emitted via onValueChange or received via the `value` prop —
|
|
136
|
+
// used to avoid re-syncing internal state when the parent echoes our
|
|
137
|
+
// own value back, and to detect genuine external `value` changes.
|
|
138
|
+
const lastCombinedRef = useRef<string>(initialValueAsString);
|
|
139
|
+
|
|
140
|
+
// Controlled mode: re-seed internal state when `value` changes externally.
|
|
141
|
+
useEffect(() => {
|
|
142
|
+
if (value === undefined) return;
|
|
143
|
+
const asString = value.toString();
|
|
144
|
+
if (asString === lastCombinedRef.current) return;
|
|
145
|
+
lastCombinedRef.current = asString;
|
|
146
|
+
const country = getDefaultCountry(defaultCountryCode, asString);
|
|
147
|
+
const localNumber =
|
|
148
|
+
asString.substring(country.dialCode.length).trim() || '';
|
|
149
|
+
setSelectedCountryCode(country.code);
|
|
150
|
+
setTypedInNumber(localNumber);
|
|
151
|
+
}, [value, defaultCountryCode]);
|
|
152
|
+
|
|
113
153
|
const handleCountryChange = (value: string) => {
|
|
114
154
|
setSelectedCountryCode(value);
|
|
115
155
|
setTypedInNumber('');
|
|
116
156
|
const newDialCode =
|
|
117
157
|
countries.find(country => country.code === value)?.dialCode || '';
|
|
158
|
+
lastCombinedRef.current = newDialCode.trim();
|
|
118
159
|
onValueChange?.({ dialCode: newDialCode, localNumber: '' });
|
|
119
160
|
};
|
|
120
161
|
|
|
121
162
|
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
122
163
|
const fullNumber = event.target.value;
|
|
123
|
-
const justNumber = fullNumber
|
|
164
|
+
const justNumber = fullNumber
|
|
165
|
+
.replace(selectedCountryDialCode, '')
|
|
166
|
+
.replace(/[^\d\s]/g, '')
|
|
167
|
+
.trim();
|
|
124
168
|
setTypedInNumber(justNumber);
|
|
169
|
+
lastCombinedRef.current = `${selectedCountryDialCode} ${justNumber}`.trim();
|
|
125
170
|
onValueChange?.({
|
|
126
171
|
dialCode: selectedCountryDialCode,
|
|
127
172
|
localNumber: justNumber,
|
|
@@ -142,15 +187,25 @@ const PhoneNumberInputRenderFunction: ForwardRefRenderFunction<
|
|
|
142
187
|
<div
|
|
143
188
|
ref={ref}
|
|
144
189
|
data-invalid={invalid || error ? 'true' : undefined}
|
|
145
|
-
className=
|
|
190
|
+
className={twMerge(
|
|
191
|
+
'flex w-full items-center overflow-hidden rounded-md border border-stroke-secondary-40 bg-surface-white p-1 text-marine-high-emphasis transition-all focus-within:border-stroke-primary-100 focus-within:shadow-[inset_0_0_0_2px_theme(colors.stroke.primary-100)] data-[invalid]:border-stroke-error-100',
|
|
192
|
+
!disabled &&
|
|
193
|
+
!readOnly &&
|
|
194
|
+
'hover:border-stroke-primary-100 hover:shadow-[inset_0_0_0_2px_theme(colors.stroke.primary-100)]',
|
|
195
|
+
(disabled || readOnly) &&
|
|
196
|
+
'border-transparent bg-surface-secondary-7 text-marine-medium-emphasis shadow-none',
|
|
197
|
+
)}
|
|
146
198
|
>
|
|
147
199
|
<SelectPrimitive.Root
|
|
148
200
|
value={selectedCountryCode}
|
|
149
201
|
onValueChange={handleCountryChange}
|
|
150
|
-
disabled={readOnly}
|
|
202
|
+
disabled={disabled || readOnly}
|
|
151
203
|
>
|
|
152
204
|
<SelectPrimitive.Trigger
|
|
153
|
-
className=
|
|
205
|
+
className={twMerge(
|
|
206
|
+
'z-[1] flex items-center gap-2 rounded-md bg-surface-secondary-10 p-3 hover:!shadow-none focus:outline-none focus-visible:shadow-focus-state',
|
|
207
|
+
(disabled || readOnly) && 'bg-transparent',
|
|
208
|
+
)}
|
|
154
209
|
aria-label={ariaLabelCountryCode}
|
|
155
210
|
>
|
|
156
211
|
<CountryFlag
|
|
@@ -191,16 +246,22 @@ const PhoneNumberInputRenderFunction: ForwardRefRenderFunction<
|
|
|
191
246
|
<input
|
|
192
247
|
id={phoneInputId}
|
|
193
248
|
type="tel"
|
|
194
|
-
className="flex-1 p-3 focus:outline-none"
|
|
249
|
+
className="flex-1 bg-transparent p-3 text-inherit focus:outline-none"
|
|
195
250
|
value={inputValue}
|
|
196
251
|
onChange={handleChange}
|
|
197
252
|
readOnly={readOnly}
|
|
253
|
+
disabled={disabled}
|
|
198
254
|
aria-label={ariaLabelPhoneNumber}
|
|
199
255
|
aria-describedby={ariaDescribedBy}
|
|
200
256
|
/>
|
|
201
257
|
|
|
202
258
|
<Form.Control type="tel" asChild>
|
|
203
|
-
<input
|
|
259
|
+
<input
|
|
260
|
+
type="hidden"
|
|
261
|
+
{...attrs}
|
|
262
|
+
disabled={disabled}
|
|
263
|
+
defaultValue={inputValue}
|
|
264
|
+
/>
|
|
204
265
|
</Form.Control>
|
|
205
266
|
</div>
|
|
206
267
|
{error && <InputFieldError id={errorId}>{error}</InputFieldError>}
|