@tui-cruises/mein-schiff-web-react-component-library 3.1.13 → 3.1.15

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,26 @@
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.15](https://bitbucket.org/yours_truly/tuic-mein-schiff-web-react-component-library/compare/v3.1.14...v3.1.15) (2026-05-19)
6
+
7
+
8
+ ### Features
9
+
10
+ * **Chip:** Add a dismissable chip variant ([73d4189](https://bitbucket.org/yours_truly/tuic-mein-schiff-web-react-component-library/commit/73d41894e86c52450ac42523923150f474e0e6f4))
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * **Checkbox:** Align hover effect with design system ([d824ffb](https://bitbucket.org/yours_truly/tuic-mein-schiff-web-react-component-library/commit/d824ffbd067cc4d19284d70f7c860d50266a7379))
16
+
17
+ ### [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)
18
+
19
+
20
+ ### Features
21
+
22
+ * **BirthdateField:** add controlled value, minYear/maxYear, ISO-8601 support ([ba03b4a](https://bitbucket.org/yours_truly/tuic-mein-schiff-web-react-component-library/commit/ba03b4a443fdfbcb90a7dd24efacdd7106545827))
23
+ * **PhoneNumberInput:** add controlled value prop ([c33f049](https://bitbucket.org/yours_truly/tuic-mein-schiff-web-react-component-library/commit/c33f049febe5ffe0304951b85a1953ecb4b5d409))
24
+
5
25
  ### [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)
6
26
 
7
27
 
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@tui-cruises/mein-schiff-web-react-component-library",
3
- "version": "3.1.13",
3
+ "version": "3.1.15",
4
4
  "main": "./index.tsx",
5
5
  "types": "./index.tsx",
6
6
  "type": "module",
7
7
  "scripts": {
8
8
  "lint": "eslint \"**/*.ts*\"",
9
- "test": "echo \"Error: no test specified\" && exit 1",
9
+ "test": "vitest run",
10
10
  "prettier": "prettier --write ./src",
11
11
  "dev": "storybook dev -p 6006",
12
12
  "build-storybook": "storybook build",
@@ -79,6 +79,9 @@
79
79
  "@storybook/addon-docs": "10.0.1",
80
80
  "@storybook/nextjs": "10.0.1",
81
81
  "@svgr/cli": "^8.0.1",
82
+ "@testing-library/jest-dom": "^6.9.1",
83
+ "@testing-library/react": "^16.3.2",
84
+ "@testing-library/user-event": "^14.6.1",
82
85
  "@types/d3": "^7.4.3",
83
86
  "@types/lodash": "^4.17.13",
84
87
  "@types/luxon": "^3.4.2",
@@ -91,6 +94,7 @@
91
94
  "esbuild": "^0.25.11",
92
95
  "eslint": "^8.47.0",
93
96
  "eslint-plugin-storybook": "10.0.1",
97
+ "jsdom": "^29.1.1",
94
98
  "postcss": "^8.4.26",
95
99
  "prettier": "^3.0.3",
96
100
  "prettier-plugin-tailwindcss": "^0.4.1",
@@ -101,6 +105,7 @@
101
105
  "tailwindcss": "^3.4.17",
102
106
  "ts-loader": "^9.5.2",
103
107
  "tsconfig-paths-webpack-plugin": "^4.2.0",
104
- "typescript": "^5.7.3"
108
+ "typescript": "^5.7.3",
109
+ "vitest": "^4.1.6"
105
110
  }
106
111
  }
@@ -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,14 +1,15 @@
1
1
  'use client';
2
2
 
3
3
  import type { ForwardRefRenderFunction, InputHTMLAttributes } from 'react';
4
- import { Icon } from '../../core/Icon';
5
4
  import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react';
6
- import { twJoin } from 'tailwind-merge';
5
+ import { Icon } from '../../core/Icon';
6
+ import { twMerge } from 'tailwind-merge';
7
7
 
8
8
  type BaseProps = {
9
9
  checked?: boolean | 'indeterminate';
10
10
  defaultChecked?: boolean | 'indeterminate';
11
11
  invalid?: string;
12
+ on?: 'white' | 'gray';
12
13
  };
13
14
 
14
15
  export type CheckboxProps = BaseProps &
@@ -17,7 +18,10 @@ export type CheckboxProps = BaseProps &
17
18
  const CheckboxRenderFunction: ForwardRefRenderFunction<
18
19
  HTMLInputElement | null,
19
20
  CheckboxProps
20
- > = ({ checked, defaultChecked, invalid, ...rest }, forwardedRef) => {
21
+ > = (
22
+ { checked, defaultChecked, invalid, on = 'gray', ...rest },
23
+ forwardedRef,
24
+ ) => {
21
25
  const ref = useRef<HTMLInputElement | null>(null);
22
26
  const checkedProp = checked === 'indeterminate' ? false : checked;
23
27
  const defaultCheckedProp =
@@ -54,15 +58,18 @@ const CheckboxRenderFunction: ForwardRefRenderFunction<
54
58
  <span className="relative inline-block h-8 w-8 flex-shrink-0 md:h-6 md:w-6">
55
59
  <input
56
60
  {...rest}
57
- className={twJoin(
58
- 'peer absolute inset-0 h-8 w-8 appearance-none rounded-sm md:h-6 md:w-6',
59
- 'before:absolute before:inset-0 before:rounded-sm before:border before:border-stroke-primary-100',
61
+ className={twMerge(
62
+ 'peer absolute inset-0 h-8 w-8 appearance-none rounded-[2px] bg-surface-white md:h-6 md:w-6',
63
+ 'before:absolute before:inset-0 before:rounded-[2px] before:border before:border-stroke-primary-100',
60
64
  'checked:before:border-transparent checked:before:bg-surface-primary-100',
61
65
  'indeterminate:before:border-transparent indeterminate:before:bg-surface-primary-100',
62
66
  'invalid:before:border-stroke-error-100',
63
- 'hover:cursor-pointer hover:bg-surface-secondary-7',
67
+ 'hover:cursor-pointer hover:before:border-4',
64
68
  'focus:outline-none focus-visible:shadow-focus-state',
65
- 'disabled:before:border-stroke-secondary-40 disabled:hover:cursor-not-allowed disabled:hover:bg-transparent',
69
+ on === 'white' &&
70
+ 'disabled:before:border-stroke-secondary-40 disabled:hover:cursor-not-allowed disabled:hover:bg-transparent',
71
+ on === 'gray' &&
72
+ 'disabled:checked:before:bg-surface-white disabled:hover:before:border disabled:hover:cursor-not-allowed disabled:bg-surface-secondary-7 disabled:hover:bg-transparent',
66
73
  )}
67
74
  type="checkbox"
68
75
  defaultChecked={defaultCheckedProp}
@@ -0,0 +1,62 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { render, screen } from '@testing-library/react';
3
+ import userEvent from '@testing-library/user-event';
4
+ import { Chip } from './Chip';
5
+
6
+ describe('Chip', () => {
7
+ describe('dismissable', () => {
8
+ it('renders dismiss button when onDismiss is provided', () => {
9
+ render(<Chip onDismiss={() => {}}>Label</Chip>);
10
+ expect(screen.getByRole('button', { name: 'Dismiss' })).toBeInTheDocument();
11
+ });
12
+
13
+ it('does not render dismiss button when onDismiss is not provided', () => {
14
+ render(<Chip>Label</Chip>);
15
+ expect(screen.queryByRole('button', { name: 'Dismiss' })).not.toBeInTheDocument();
16
+ });
17
+
18
+ it('calls onDismiss when dismiss button is clicked', async () => {
19
+ const user = userEvent.setup();
20
+ const onDismiss = vi.fn();
21
+ render(<Chip onDismiss={onDismiss}>Label</Chip>);
22
+
23
+ await user.click(screen.getByRole('button', { name: 'Dismiss' }));
24
+ expect(onDismiss).toHaveBeenCalledTimes(1);
25
+ });
26
+
27
+ it('calls onDismiss on Enter key', async () => {
28
+ const user = userEvent.setup();
29
+ const onDismiss = vi.fn();
30
+ render(<Chip onDismiss={onDismiss}>Label</Chip>);
31
+
32
+ screen.getByRole('button', { name: 'Dismiss' }).focus();
33
+ await user.keyboard('{Enter}');
34
+ expect(onDismiss).toHaveBeenCalledTimes(1);
35
+ });
36
+
37
+ it('calls onDismiss on Space key', async () => {
38
+ const user = userEvent.setup();
39
+ const onDismiss = vi.fn();
40
+ render(<Chip onDismiss={onDismiss}>Label</Chip>);
41
+
42
+ screen.getByRole('button', { name: 'Dismiss' }).focus();
43
+ await user.keyboard(' ');
44
+ expect(onDismiss).toHaveBeenCalledTimes(1);
45
+ });
46
+
47
+ it('does not propagate click to parent chip button', async () => {
48
+ const user = userEvent.setup();
49
+ const onDismiss = vi.fn();
50
+ const onClick = vi.fn();
51
+ render(
52
+ <Chip onClick={onClick} onDismiss={onDismiss}>
53
+ Label
54
+ </Chip>,
55
+ );
56
+
57
+ await user.click(screen.getByRole('button', { name: 'Dismiss' }));
58
+ expect(onDismiss).toHaveBeenCalledTimes(1);
59
+ expect(onClick).not.toHaveBeenCalled();
60
+ });
61
+ });
62
+ });
@@ -1,7 +1,8 @@
1
- import type { ReactNode, RefObject } from 'react';
1
+ import type { ButtonHTMLAttributes, ReactNode, RefObject } from 'react';
2
2
  import { twJoin, twMerge } from 'tailwind-merge';
3
3
  import { Slot, Slottable } from '@radix-ui/react-slot';
4
4
  import { Pictogram, PictogramName } from '../Pictogram';
5
+ import { Icon } from '../Icon';
5
6
 
6
7
  /**
7
8
  * All allowed variants for the Chip component.
@@ -11,15 +12,13 @@ type Variant = 'text' | 'pictogram';
11
12
  /**
12
13
  * Props for the Chip component.
13
14
  */
14
- export type ChipProps = {
15
+ export type ChipProps = ButtonHTMLAttributes<HTMLButtonElement> & {
15
16
  asChild?: boolean;
16
- className?: string;
17
17
  variant?: Variant;
18
18
  pictogram?: PictogramName;
19
19
  on?: 'white' | 'gray';
20
- children?: ReactNode;
21
20
  active?: boolean;
22
- disabled?: boolean;
21
+ onDismiss?: () => void;
23
22
  ref?: RefObject<HTMLButtonElement>;
24
23
  };
25
24
 
@@ -34,6 +33,7 @@ const Chip = ({
34
33
  on = 'white',
35
34
  active = false,
36
35
  disabled = false,
36
+ onDismiss,
37
37
  children,
38
38
  ref,
39
39
  ...args
@@ -41,12 +41,17 @@ const Chip = ({
41
41
  const Element = asChild ? Slot : 'button';
42
42
 
43
43
  const borderRadius = variant === 'text' ? 'rounded-full' : 'rounded-md';
44
- const padding = variant === 'text' ? 'px-4 py-3' : 'p-3 lg:p-2';
44
+ const padding =
45
+ variant === 'text'
46
+ ? onDismiss
47
+ ? 'pl-4 pr-3 py-2'
48
+ : 'px-4 py-3'
49
+ : 'p-3 lg:p-2';
45
50
  const variantClasses = twMerge(
46
51
  'border-none text-center text-sm font-semibold transition-all',
47
52
 
48
53
  // Variants
49
- variant === 'text' && [
54
+ variant === 'text' && !onDismiss && [
50
55
  // Shared
51
56
  'text-marine-high-emphasis hover:bg-surface-primary-100',
52
57
 
@@ -63,6 +68,10 @@ const Chip = ({
63
68
  'bg-surface-white shadow-[inset_0_0_0_2px_theme(colors.stroke.secondary-20)]',
64
69
  ],
65
70
 
71
+ variant === 'text' && onDismiss && [
72
+ 'text-marine-high-emphasis bg-surface-white shadow-[inset_0_0_0_1px_theme(colors.stroke.secondary-20)] cursor-default',
73
+ ],
74
+
66
75
  variant === 'pictogram' && [
67
76
  // Shared
68
77
  'bg-surface-white shadow-[inset_0_0_0_2px_theme(colors.stroke.primary-100)] hover:shadow-[inset_0_0_0_4px_theme(colors.stroke.primary-100)]',
@@ -83,7 +92,7 @@ const Chip = ({
83
92
  ref={ref}
84
93
  {...args}
85
94
  className={twJoin(
86
- 'inline-flex items-center justify-center',
95
+ 'inline-flex items-center justify-center gap-2',
87
96
  borderRadius,
88
97
  padding,
89
98
  variantClasses,
@@ -92,6 +101,27 @@ const Chip = ({
92
101
  >
93
102
  <Slottable>{children ?? ''}</Slottable>
94
103
  {pictogram && <Pictogram name={pictogram} size="sm" />}
104
+ {onDismiss && (
105
+ <span
106
+ role="button"
107
+ aria-label="Dismiss"
108
+ tabIndex={0}
109
+ onClick={(e) => {
110
+ e.stopPropagation();
111
+ onDismiss();
112
+ }}
113
+ onKeyDown={(e) => {
114
+ if (e.key === 'Enter' || e.key === ' ') {
115
+ e.preventDefault();
116
+ e.stopPropagation();
117
+ onDismiss();
118
+ }
119
+ }}
120
+ className="inline-flex cursor-pointer"
121
+ >
122
+ <Icon name="cancel" size="xs" />
123
+ </span>
124
+ )}
95
125
  </Element>
96
126
  );
97
127
  };
@@ -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';
@@ -17,7 +24,10 @@ import { CountryCode, parsePhoneNumber } from 'libphonenumber-js';
17
24
  import { InputFieldError } from '../InputField/InputFieldError';
18
25
  import { twMerge } from 'tailwind-merge';
19
26
 
20
- type PhoneNumberInputProps = InputHTMLAttributes<HTMLInputElement> & {
27
+ type PhoneNumberInputProps = Omit<
28
+ InputHTMLAttributes<HTMLInputElement>,
29
+ 'value'
30
+ > & {
21
31
  countries?: PhoneNumberCountry[];
22
32
  defaultCountryCode?: string;
23
33
  invalid?: boolean;
@@ -25,6 +35,12 @@ type PhoneNumberInputProps = InputHTMLAttributes<HTMLInputElement> & {
25
35
  error?: string;
26
36
  ariaLabelCountryCode?: string;
27
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;
28
44
  /** Called whenever the value changes with the dial code and number separately. */
29
45
  onValueChange?: (value: { dialCode: string; localNumber: string }) => void;
30
46
  };
@@ -89,19 +105,22 @@ const PhoneNumberInputRenderFunction: ForwardRefRenderFunction<
89
105
  onValueChange,
90
106
  readOnly,
91
107
  disabled,
108
+ defaultValue,
109
+ value,
92
110
  ...attrs
93
111
  } = props;
94
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
+
95
117
  const defaultCountry = getDefaultCountry(
96
118
  defaultCountryCode,
97
- props.defaultValue,
119
+ initialValueAsString,
98
120
  );
99
121
 
100
122
  const [typedInNumber, setTypedInNumber] = useState(
101
- props.defaultValue
102
- ?.toString()
103
- .substring(defaultCountry.dialCode.length)
104
- .trim() || '',
123
+ initialValueAsString.substring(defaultCountry.dialCode.length).trim() || '',
105
124
  );
106
125
 
107
126
  const [selectedCountryCode, setSelectedCountryCode] = useState<string>(
@@ -112,11 +131,31 @@ const PhoneNumberInputRenderFunction: ForwardRefRenderFunction<
112
131
  countries.find(country => country.code === selectedCountryCode)?.dialCode ||
113
132
  '';
114
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
+
115
153
  const handleCountryChange = (value: string) => {
116
154
  setSelectedCountryCode(value);
117
155
  setTypedInNumber('');
118
156
  const newDialCode =
119
157
  countries.find(country => country.code === value)?.dialCode || '';
158
+ lastCombinedRef.current = newDialCode.trim();
120
159
  onValueChange?.({ dialCode: newDialCode, localNumber: '' });
121
160
  };
122
161
 
@@ -127,6 +166,7 @@ const PhoneNumberInputRenderFunction: ForwardRefRenderFunction<
127
166
  .replace(/[^\d\s]/g, '')
128
167
  .trim();
129
168
  setTypedInNumber(justNumber);
169
+ lastCombinedRef.current = `${selectedCountryDialCode} ${justNumber}`.trim();
130
170
  onValueChange?.({
131
171
  dialCode: selectedCountryDialCode,
132
172
  localNumber: justNumber,