@pichetch08/trip-ui 0.2.1 → 0.2.3
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.
|
@@ -29,12 +29,21 @@ function Modal({
|
|
|
29
29
|
useEffect(() => {
|
|
30
30
|
if (!open) return;
|
|
31
31
|
const dialog = dialogRef.current;
|
|
32
|
-
const
|
|
33
|
-
|
|
32
|
+
const firstInput = dialog == null ? void 0 : dialog.querySelector(
|
|
33
|
+
"input:not([disabled]), textarea:not([disabled])"
|
|
34
34
|
);
|
|
35
|
-
if (
|
|
36
|
-
|
|
35
|
+
if (firstInput) {
|
|
36
|
+
firstInput.focus();
|
|
37
|
+
return;
|
|
37
38
|
}
|
|
39
|
+
const first = dialog == null ? void 0 : dialog.querySelector(
|
|
40
|
+
'button:not([disabled]), [href], [tabindex]:not([tabindex="-1"])'
|
|
41
|
+
);
|
|
42
|
+
first == null ? void 0 : first.focus();
|
|
43
|
+
}, [open]);
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
if (!open) return;
|
|
46
|
+
const dialog = dialogRef.current;
|
|
38
47
|
const onKey = (e) => {
|
|
39
48
|
if (e.key === "Escape" && !blocking) {
|
|
40
49
|
e.preventDefault();
|
|
@@ -16,9 +16,10 @@ interface SelectPickerProps {
|
|
|
16
16
|
searchable?: boolean;
|
|
17
17
|
icon?: string;
|
|
18
18
|
name?: string;
|
|
19
|
+
size?: "sm" | "md";
|
|
19
20
|
onOpen?: () => void;
|
|
20
21
|
onClose?: () => void;
|
|
21
22
|
}
|
|
22
|
-
declare function SelectPicker({ label, value, onChange, options, placeholder, searchPlaceholder, emptyText, required, error, searchable, icon, name, onOpen, onClose, }: SelectPickerProps): React.ReactNode;
|
|
23
|
+
declare function SelectPicker({ label, value, onChange, options, placeholder, searchPlaceholder, emptyText, required, error, searchable, icon, name, size, onOpen, onClose, }: SelectPickerProps): React.ReactNode;
|
|
23
24
|
|
|
24
25
|
export { type SelectOption, SelectPicker };
|
|
@@ -15,6 +15,7 @@ function SelectPicker({
|
|
|
15
15
|
searchable = true,
|
|
16
16
|
icon,
|
|
17
17
|
name,
|
|
18
|
+
size = "md",
|
|
18
19
|
onOpen,
|
|
19
20
|
onClose
|
|
20
21
|
}) {
|
|
@@ -93,14 +94,14 @@ function SelectPicker({
|
|
|
93
94
|
"aria-controls": "select-picker-listbox",
|
|
94
95
|
"aria-required": required,
|
|
95
96
|
onClick: () => toggleOpen(!open),
|
|
96
|
-
className: `relative w-full bg-surface-container-low border rounded-xl
|
|
97
|
+
className: `relative w-full bg-surface-container-low border rounded-xl text-left transition-all duration-200 font-medium outline-none active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/30 pr-10 ${size === "sm" ? "py-2 px-3 text-sm" : "py-4 px-6"} ${icon ? size === "sm" ? "pl-8" : "pl-12" : ""} ${open ? "bg-surface ring-2 ring-primary/20 border-primary" : error ? "border-red-400 bg-red-50/30" : "border-transparent hover:border-outline-variant"}`,
|
|
97
98
|
children: [
|
|
98
|
-
icon && /* @__PURE__ */ jsx("span", { className:
|
|
99
|
+
icon && /* @__PURE__ */ jsx("span", { className: `material-symbols-outlined absolute top-1/2 -translate-y-1/2 text-on-surface-variant ${size === "sm" ? "left-2.5 text-base" : "left-4"}`, children: icon }),
|
|
99
100
|
selectedOption ? /* @__PURE__ */ jsxs("span", { className: "flex items-center gap-2 text-on-surface", children: [
|
|
100
101
|
selectedOption.icon && /* @__PURE__ */ jsx("span", { className: "material-symbols-outlined text-base text-on-surface-variant", children: selectedOption.icon }),
|
|
101
102
|
selectedOption.label
|
|
102
103
|
] }) : /* @__PURE__ */ jsx("span", { className: "text-outline/40", children: placeholder }),
|
|
103
|
-
/* @__PURE__ */ jsx("span", { className: `material-symbols-outlined absolute right-
|
|
104
|
+
/* @__PURE__ */ jsx("span", { className: `material-symbols-outlined absolute right-2.5 top-1/2 -translate-y-1/2 text-on-surface-variant transition-transform ${size === "sm" ? "text-base" : "text-lg"} ${open ? "rotate-180" : ""}`, children: "expand_more" })
|
|
104
105
|
]
|
|
105
106
|
}
|
|
106
107
|
),
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
getDaysUntil,
|
|
4
|
+
getTripDuration,
|
|
5
|
+
getDayGradientClass,
|
|
6
|
+
getActivityTypeStyle,
|
|
7
|
+
getActivityEmoji,
|
|
8
|
+
getDayEmoji,
|
|
9
|
+
detectMapProvider,
|
|
10
|
+
detectMapProviderFromActivities,
|
|
11
|
+
buildRouteUrl,
|
|
12
|
+
getMapProviderLabel,
|
|
13
|
+
getMapButtonLabel,
|
|
14
|
+
getRouteButtonLabel,
|
|
15
|
+
formatDateRange,
|
|
16
|
+
cn
|
|
17
|
+
} from "./trip-utils";
|
|
18
|
+
describe("cn", () => {
|
|
19
|
+
it("merges class names", () => {
|
|
20
|
+
expect(cn("a", "b")).toBe("a b");
|
|
21
|
+
});
|
|
22
|
+
it("handles conditional falsy values", () => {
|
|
23
|
+
expect(cn("a", false, void 0, null, "c")).toBe("a c");
|
|
24
|
+
});
|
|
25
|
+
it("merges tailwind conflicts correctly (last wins)", () => {
|
|
26
|
+
const result = cn("px-2", "px-4");
|
|
27
|
+
expect(result).toBe("px-4");
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
describe("getDaysUntil", () => {
|
|
31
|
+
it("returns a positive number for a future date", () => {
|
|
32
|
+
const future = new Date(Date.now() + 5 * 24 * 60 * 60 * 1e3).toISOString().split("T")[0];
|
|
33
|
+
expect(getDaysUntil(future)).toBeGreaterThan(0);
|
|
34
|
+
});
|
|
35
|
+
it("returns a negative number for a past date", () => {
|
|
36
|
+
const past = new Date(Date.now() - 5 * 24 * 60 * 60 * 1e3).toISOString().split("T")[0];
|
|
37
|
+
expect(getDaysUntil(past)).toBeLessThan(0);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
describe("getTripDuration", () => {
|
|
41
|
+
it("returns 1 for same-day start and end", () => {
|
|
42
|
+
expect(getTripDuration("2026-06-01", "2026-06-01")).toBe(1);
|
|
43
|
+
});
|
|
44
|
+
it("returns correct count for multi-day trip", () => {
|
|
45
|
+
expect(getTripDuration("2026-06-01", "2026-06-07")).toBe(7);
|
|
46
|
+
});
|
|
47
|
+
it("returns 3 for a 3-day trip", () => {
|
|
48
|
+
expect(getTripDuration("2026-06-10", "2026-06-12")).toBe(3);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
describe("getDayGradientClass", () => {
|
|
52
|
+
it("returns a non-empty class string", () => {
|
|
53
|
+
expect(getDayGradientClass(1)).toBeTruthy();
|
|
54
|
+
expect(typeof getDayGradientClass(1)).toBe("string");
|
|
55
|
+
});
|
|
56
|
+
it("cycles through 8 gradients", () => {
|
|
57
|
+
expect(getDayGradientClass(1)).toBe(getDayGradientClass(9));
|
|
58
|
+
expect(getDayGradientClass(2)).toBe(getDayGradientClass(10));
|
|
59
|
+
});
|
|
60
|
+
it("returns different classes for different days within a cycle", () => {
|
|
61
|
+
const classes = new Set([1, 2, 3, 4, 5, 6, 7, 8].map(getDayGradientClass));
|
|
62
|
+
expect(classes.size).toBe(8);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
describe("getActivityTypeStyle", () => {
|
|
66
|
+
it.each(["attraction", "restaurant", "hotel", "transport", "shopping", "other"])(
|
|
67
|
+
"returns bg, text, border for type '%s'",
|
|
68
|
+
(type) => {
|
|
69
|
+
const style = getActivityTypeStyle(type);
|
|
70
|
+
expect(style.bg).toBeTruthy();
|
|
71
|
+
expect(style.text).toBeTruthy();
|
|
72
|
+
expect(style.border).toBeTruthy();
|
|
73
|
+
}
|
|
74
|
+
);
|
|
75
|
+
it("falls back to 'other' style for unknown type", () => {
|
|
76
|
+
expect(getActivityTypeStyle("unknown")).toEqual(getActivityTypeStyle("other"));
|
|
77
|
+
});
|
|
78
|
+
it("returns different styles for different types", () => {
|
|
79
|
+
expect(getActivityTypeStyle("attraction")).not.toEqual(getActivityTypeStyle("restaurant"));
|
|
80
|
+
expect(getActivityTypeStyle("hotel")).not.toEqual(getActivityTypeStyle("transport"));
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
describe("getActivityEmoji", () => {
|
|
84
|
+
it("returns the custom emoji when provided", () => {
|
|
85
|
+
expect(getActivityEmoji("\u{1F3EF}", "attraction")).toBe("\u{1F3EF}");
|
|
86
|
+
});
|
|
87
|
+
it("returns type-based emoji when custom emoji is empty", () => {
|
|
88
|
+
const emoji = getActivityEmoji("", "restaurant");
|
|
89
|
+
expect(emoji).toBeTruthy();
|
|
90
|
+
expect(emoji).not.toBe("");
|
|
91
|
+
});
|
|
92
|
+
it("returns type-based emoji when custom emoji is undefined", () => {
|
|
93
|
+
const emoji = getActivityEmoji(void 0, "hotel");
|
|
94
|
+
expect(emoji).toBeTruthy();
|
|
95
|
+
});
|
|
96
|
+
it("returns a fallback for unknown type", () => {
|
|
97
|
+
const emoji = getActivityEmoji("", "unknown-type");
|
|
98
|
+
expect(emoji).toBeTruthy();
|
|
99
|
+
});
|
|
100
|
+
it("returns the custom emoji even if it only contains whitespace \u2014 trims it", () => {
|
|
101
|
+
const emoji = getActivityEmoji(" ", "attraction");
|
|
102
|
+
expect(emoji).not.toBe(" ");
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
describe("getDayEmoji", () => {
|
|
106
|
+
it("returns the first word when subtitle starts with a non-ASCII char (emoji)", () => {
|
|
107
|
+
expect(getDayEmoji("\u{1F305} \u0E0A\u0E21\u0E1E\u0E23\u0E30\u0E2D\u0E32\u0E17\u0E34\u0E15\u0E22\u0E4C", 0)).toBe("\u{1F305}");
|
|
108
|
+
});
|
|
109
|
+
it("falls back to a rotated list emoji when subtitle is undefined", () => {
|
|
110
|
+
const e = getDayEmoji(void 0, 0);
|
|
111
|
+
expect(e).toBeTruthy();
|
|
112
|
+
});
|
|
113
|
+
it("rotates through list when subtitle is plain text", () => {
|
|
114
|
+
const e0 = getDayEmoji("Day at the beach", 0);
|
|
115
|
+
const e1 = getDayEmoji("Day at the beach", 1);
|
|
116
|
+
expect(e0).not.toBe(e1);
|
|
117
|
+
});
|
|
118
|
+
it("cycles emojis via modulo", () => {
|
|
119
|
+
expect(getDayEmoji(void 0, 0)).toBe(getDayEmoji(void 0, 8));
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
describe("detectMapProvider", () => {
|
|
123
|
+
it.each([
|
|
124
|
+
["https://www.google.com/maps/place/Osaka", "google"],
|
|
125
|
+
["https://maps.google.com/?q=35,139", "google"],
|
|
126
|
+
["https://map.baidu.com/poi", "baidu"],
|
|
127
|
+
["https://www.amap.com/place/abc", "amap"],
|
|
128
|
+
["https://uri.amap.com/navigation", "amap"],
|
|
129
|
+
["https://maps.apple.com/?q=Tokyo", "apple"]
|
|
130
|
+
])("detects %s as %s", (url, expected) => {
|
|
131
|
+
expect(detectMapProvider(url)).toBe(expected);
|
|
132
|
+
});
|
|
133
|
+
it("returns 'unknown' for empty string", () => {
|
|
134
|
+
expect(detectMapProvider("")).toBe("unknown");
|
|
135
|
+
});
|
|
136
|
+
it("returns 'unknown' for unrecognised URL", () => {
|
|
137
|
+
expect(detectMapProvider("https://maps.yahoo.com/")).toBe("unknown");
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
describe("detectMapProviderFromActivities", () => {
|
|
141
|
+
it("returns 'unknown' when no activities have mapsLink", () => {
|
|
142
|
+
expect(detectMapProviderFromActivities([
|
|
143
|
+
{ mapsLink: "", placeName: "A" },
|
|
144
|
+
{ mapsLink: "", placeName: "B" }
|
|
145
|
+
])).toBe("unknown");
|
|
146
|
+
});
|
|
147
|
+
it("uses the first activity with a mapsLink", () => {
|
|
148
|
+
const result = detectMapProviderFromActivities([
|
|
149
|
+
{ mapsLink: "", placeName: "A" },
|
|
150
|
+
{ mapsLink: "https://www.google.com/maps/place/B", placeName: "B" },
|
|
151
|
+
{ mapsLink: "https://map.baidu.com/place/C", placeName: "C" }
|
|
152
|
+
]);
|
|
153
|
+
expect(result).toBe("google");
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
describe("buildRouteUrl", () => {
|
|
157
|
+
it("returns null when fewer than 2 places provided", () => {
|
|
158
|
+
expect(buildRouteUrl([], "google")).toBeNull();
|
|
159
|
+
expect(buildRouteUrl(["Only one"], "google")).toBeNull();
|
|
160
|
+
});
|
|
161
|
+
it("builds a Google Maps route URL for 2 places", () => {
|
|
162
|
+
const url = buildRouteUrl(["Osaka Castle", "Dotonbori"], "google");
|
|
163
|
+
expect(url).toContain("google.com/maps/dir");
|
|
164
|
+
expect(url).toContain(encodeURIComponent("Osaka Castle"));
|
|
165
|
+
});
|
|
166
|
+
it("includes intermediate waypoints in Google URL", () => {
|
|
167
|
+
const url = buildRouteUrl(["A", "B", "C"], "google");
|
|
168
|
+
expect(url).toContain(encodeURIComponent("B"));
|
|
169
|
+
});
|
|
170
|
+
it("builds a Baidu route URL", () => {
|
|
171
|
+
const url = buildRouteUrl(["\u4E0A\u6D77", "\u5317\u4EAC"], "baidu");
|
|
172
|
+
expect(url).toContain("map.baidu.com");
|
|
173
|
+
expect(url).toContain(encodeURIComponent("\u4E0A\u6D77"));
|
|
174
|
+
});
|
|
175
|
+
it("builds an Amap route URL", () => {
|
|
176
|
+
const url = buildRouteUrl(["\u6210\u90FD", "\u91CD\u5E86"], "amap");
|
|
177
|
+
expect(url).toContain("amap.com");
|
|
178
|
+
});
|
|
179
|
+
it("builds an Apple Maps route URL", () => {
|
|
180
|
+
const url = buildRouteUrl(["Tokyo", "Kyoto"], "apple");
|
|
181
|
+
expect(url).toContain("maps.apple.com");
|
|
182
|
+
});
|
|
183
|
+
it("falls back to Google for unknown provider", () => {
|
|
184
|
+
const url = buildRouteUrl(["A", "B"], "unknown");
|
|
185
|
+
expect(url).toContain("google.com/maps/dir");
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
describe("getMapProviderLabel", () => {
|
|
189
|
+
it.each([
|
|
190
|
+
["google", "Google Maps"],
|
|
191
|
+
["baidu", "\u767E\u5EA6\u5730\u56FE"],
|
|
192
|
+
["amap", "\u9AD8\u5FB7\u5730\u56FE"],
|
|
193
|
+
["apple", "Apple Maps"],
|
|
194
|
+
["unknown", "\u0E41\u0E1C\u0E19\u0E17\u0E35\u0E48"]
|
|
195
|
+
])("returns '%s' label for provider '%s'", (provider, label) => {
|
|
196
|
+
expect(getMapProviderLabel(provider)).toBe(label);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
describe("getMapButtonLabel", () => {
|
|
200
|
+
it("returns Thai-script label for google/unknown", () => {
|
|
201
|
+
expect(getMapButtonLabel("google")).toBe("\u0E40\u0E1B\u0E34\u0E14\u0E41\u0E1C\u0E19\u0E17\u0E35\u0E48");
|
|
202
|
+
expect(getMapButtonLabel("unknown")).toBe("\u0E40\u0E1B\u0E34\u0E14\u0E41\u0E1C\u0E19\u0E17\u0E35\u0E48");
|
|
203
|
+
});
|
|
204
|
+
it("includes provider name for baidu/amap/apple", () => {
|
|
205
|
+
expect(getMapButtonLabel("baidu")).toContain("\u767E\u5EA6");
|
|
206
|
+
expect(getMapButtonLabel("amap")).toContain("\u9AD8\u5FB7");
|
|
207
|
+
expect(getMapButtonLabel("apple")).toContain("Apple");
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
describe("getRouteButtonLabel", () => {
|
|
211
|
+
it("returns route label for each provider", () => {
|
|
212
|
+
expect(getRouteButtonLabel("google")).toContain("\u0E14\u0E39\u0E40\u0E2A\u0E49\u0E19\u0E17\u0E32\u0E07");
|
|
213
|
+
expect(getRouteButtonLabel("baidu")).toContain("\u767E\u5EA6\u5730\u56FE");
|
|
214
|
+
expect(getRouteButtonLabel("amap")).toContain("\u9AD8\u5FB7\u5730\u56FE");
|
|
215
|
+
expect(getRouteButtonLabel("apple")).toContain("Apple Maps");
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
describe("formatDateRange", () => {
|
|
219
|
+
it("uses condensed format when start and end are in the same month", () => {
|
|
220
|
+
const result = formatDateRange("2026-06-01", "2026-06-07");
|
|
221
|
+
expect(result).toMatch(/1/);
|
|
222
|
+
expect(result).toMatch(/7/);
|
|
223
|
+
expect(result).toMatch(/Jun/);
|
|
224
|
+
const junCount = (result.match(/Jun/g) || []).length;
|
|
225
|
+
expect(junCount).toBe(1);
|
|
226
|
+
});
|
|
227
|
+
it("uses expanded format when start and end are in different months", () => {
|
|
228
|
+
const result = formatDateRange("2026-05-28", "2026-06-03");
|
|
229
|
+
expect(result).toContain("May");
|
|
230
|
+
expect(result).toContain("Jun");
|
|
231
|
+
});
|
|
232
|
+
it("includes the year of the end date", () => {
|
|
233
|
+
const result = formatDateRange("2026-06-01", "2026-06-07");
|
|
234
|
+
expect(result).toContain("2026");
|
|
235
|
+
});
|
|
236
|
+
});
|