@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tui-cruises/mein-schiff-web-react-component-library",
3
- "version": "3.1.12",
3
+ "version": "3.1.14",
4
4
  "main": "./index.tsx",
5
5
  "types": "./index.tsx",
6
6
  "type": "module",
@@ -42,10 +42,17 @@ export type BirthdateFieldProps = InputAttributes & {
42
42
  className?: string;
43
43
  locale?: string;
44
44
  onChange?: (value: string) => void;
45
- defaultValue?: string; // YYYY-MM-JJ
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
- const parts = value.split('-');
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
- const defaultValueAsString = defaultValue?.toString() ?? '';
147
- const initials = parseInitialValue(defaultValueAsString);
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
- const prevValue = useRef(defaultValueAsString);
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={part === 'year' ? new Date().getFullYear() - 100 : 1} // Oldest passenger age: 100
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 { InputHTMLAttributes, useRef, useState, useId, ReactNode } from 'react';
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 = InputHTMLAttributes<HTMLInputElement> & {
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
- props.defaultValue,
119
+ initialValueAsString,
96
120
  );
97
121
 
98
122
  const [typedInNumber, setTypedInNumber] = useState(
99
- props.defaultValue
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.replace(selectedCountryDialCode, '').trim();
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="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)] hover:border-stroke-primary-100 hover:shadow-[inset_0_0_0_2px_theme(colors.stroke.primary-100)] data-[invalid]:border-stroke-error-100"
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="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"
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 type="hidden" {...attrs} defaultValue={inputValue} />
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>}