@bearmenu/ui 0.8.12 → 0.8.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.
Files changed (299) hide show
  1. package/dist/{chunk-66MAPLXS.js → chunk-2ADCZXZJ.js} +1 -1
  2. package/dist/{chunk-2DEIXTBN.js → chunk-2ZXIXQIU.js} +1 -1
  3. package/dist/{chunk-YOKHBAIH.js → chunk-4OF4T4AI.js} +12 -11
  4. package/dist/{chunk-MZL7CGCL.js → chunk-4XDJXMLF.js} +2 -2
  5. package/dist/{chunk-YYGB4G4Q.js → chunk-CZK2MRS2.js} +1 -1
  6. package/dist/chunk-DJD3EMPK.js +126 -0
  7. package/dist/chunk-EZG2NELX.js +5 -0
  8. package/dist/{chunk-VAXOQS7W.js → chunk-F76NCVVR.js} +1 -1
  9. package/dist/{chunk-VG3PND3D.js → chunk-FMTJGO76.js} +1 -1
  10. package/dist/{chunk-UXWAOISG.js → chunk-FVSKGCK3.js} +2 -2
  11. package/dist/{chunk-2XCZJWTV.js → chunk-GNCWJWFE.js} +1 -1
  12. package/dist/{chunk-X3WMRKOW.js → chunk-HDR7V6IZ.js} +1 -1
  13. package/dist/{chunk-HSIYYA2E.js → chunk-HQU6QVAN.js} +5 -5
  14. package/dist/chunk-I5V446BX.js +27 -0
  15. package/dist/{chunk-WCTHZKU7.js → chunk-ICOU5QHT.js} +1 -1
  16. package/dist/{chunk-G7IFL4Z4.js → chunk-L3VEMP5A.js} +57 -9
  17. package/dist/chunk-LQWGZIYA.js +5 -0
  18. package/dist/{chunk-XNA2INVQ.js → chunk-MCOHLWRS.js} +2 -2
  19. package/dist/{chunk-3GWWZKX7.js → chunk-MMUBH76A.js} +21 -1
  20. package/dist/{chunk-ZDAHKI3A.js → chunk-MWB7S54O.js} +1 -1
  21. package/dist/{chunk-D5QHEHXJ.js → chunk-NMEVJT5Q.js} +1 -1
  22. package/dist/{chunk-3TNPSN4D.js → chunk-NSNEANOQ.js} +2 -2
  23. package/dist/{chunk-WSRWFA6K.js → chunk-NWZGSLPU.js} +1 -1
  24. package/dist/{chunk-LEAYBICI.js → chunk-OIV7B44M.js} +1 -1
  25. package/dist/{chunk-2CXU5WZO.js → chunk-P43B3ONY.js} +1 -1
  26. package/dist/{chunk-FRPPIXMY.js → chunk-PDTPO6NG.js} +1 -1
  27. package/dist/{chunk-6XMYEZVR.js → chunk-PX6RICS5.js} +1 -1
  28. package/dist/{chunk-6TEEY5YA.js → chunk-S33K4RC5.js} +1 -1
  29. package/dist/{chunk-3VMDCCCX.js → chunk-SC2VUTUI.js} +1 -1
  30. package/dist/{chunk-24RFI4RQ.js → chunk-SXQZ7DLH.js} +4 -4
  31. package/dist/{chunk-UDTG3RSE.js → chunk-T6S2ZBV6.js} +2 -2
  32. package/dist/{chunk-DTXNDLSE.js → chunk-WYTJR2YU.js} +16 -1
  33. package/dist/{chunk-QXHMPJ35.js → chunk-XBBEX4SF.js} +1 -1
  34. package/dist/chunk-XCB3F6SF.js +16 -0
  35. package/dist/{chunk-7BWTYWM3.js → chunk-ZLMRVBD7.js} +1 -1
  36. package/dist/{chunk-7A42J73Q.js → chunk-ZLPWEHZO.js} +1 -1
  37. package/dist/components/accordion.js +1 -1
  38. package/dist/components/alert-dialog.js +2 -2
  39. package/dist/components/alert.d.ts +2 -2
  40. package/dist/components/alert.js +2 -2
  41. package/dist/components/aspect-ratio.js +2 -2
  42. package/dist/components/avatar.js +1 -1
  43. package/dist/components/badge.d.ts +1 -1
  44. package/dist/components/badge.js +2 -2
  45. package/dist/components/breadcrumb.js +1 -1
  46. package/dist/components/button-group.js +1 -1
  47. package/dist/components/button.d.ts +1 -1
  48. package/dist/components/button.js +2 -2
  49. package/dist/components/calendar.js +2 -2
  50. package/dist/components/card.d.ts +2 -2
  51. package/dist/components/card.js +3 -3
  52. package/dist/components/carousel.js +2 -2
  53. package/dist/components/category-deck.js +2 -2
  54. package/dist/components/checkbox.js +2 -2
  55. package/dist/components/circular-progress.js +1 -1
  56. package/dist/components/collapsible.js +1 -1
  57. package/dist/components/combobox.js +5 -5
  58. package/dist/components/command.d.ts +15 -15
  59. package/dist/components/command.js +3 -3
  60. package/dist/components/concept-card.js +2 -2
  61. package/dist/components/concept-compare-rail.js +3 -3
  62. package/dist/components/concept-compare.d.ts +2 -1
  63. package/dist/components/concept-compare.js +5 -5
  64. package/dist/components/concept-convergence.js +4 -4
  65. package/dist/components/convergence.js +3 -3
  66. package/dist/components/coverflow.js +1 -1
  67. package/dist/components/currency-input.js +2 -2
  68. package/dist/components/date-input.d.ts +1 -1
  69. package/dist/components/date-input.js +2 -2
  70. package/dist/components/day-rail-chip.js +2 -2
  71. package/dist/components/description-list.js +1 -1
  72. package/dist/components/dialog.js +2 -2
  73. package/dist/components/doorway-tiles.d.ts +57 -0
  74. package/dist/components/doorway-tiles.js +86 -0
  75. package/dist/components/drawer.js +1 -1
  76. package/dist/components/drift-rail.js +1 -1
  77. package/dist/components/dropdown-menu.js +1 -1
  78. package/dist/components/empty-state.js +3 -3
  79. package/dist/components/event-card.js +4 -4
  80. package/dist/components/event-compact-row.js +4 -4
  81. package/dist/components/event-day-header.js +2 -2
  82. package/dist/components/event-day-separator.js +3 -3
  83. package/dist/components/event-detail-panel.js +4 -4
  84. package/dist/components/event-hub-hero.js +2 -2
  85. package/dist/components/event-index-row.js +4 -4
  86. package/dist/components/event-list-row-wide.js +5 -5
  87. package/dist/components/event-list-row.js +5 -5
  88. package/dist/components/event-poster-fallback.js +1 -1
  89. package/dist/components/event-types.js +1 -1
  90. package/dist/components/events-empty-state.js +1 -1
  91. package/dist/components/explainer-steps.js +1 -1
  92. package/dist/components/facet-group.js +2 -2
  93. package/dist/components/featured-rail-item.js +2 -2
  94. package/dist/components/field.js +2 -2
  95. package/dist/components/filter-category-row.js +3 -3
  96. package/dist/components/form.js +2 -2
  97. package/dist/components/glass-ground.js +2 -2
  98. package/dist/components/grade-atom.d.ts +3 -0
  99. package/dist/components/grade-atom.js +4 -0
  100. package/dist/components/haptic-button.js +2 -2
  101. package/dist/components/hour-band.js +4 -4
  102. package/dist/components/hour-group.js +4 -4
  103. package/dist/components/hub-surface.js +1 -1
  104. package/dist/components/hue-chip.js +1 -1
  105. package/dist/components/icon.js +1 -1
  106. package/dist/components/input-group.js +1 -1
  107. package/dist/components/input-otp.js +1 -1
  108. package/dist/components/input.js +2 -2
  109. package/dist/components/item.d.ts +2 -2
  110. package/dist/components/item.js +1 -1
  111. package/dist/components/kbd.js +1 -1
  112. package/dist/components/kitchen-dossier.d.ts +2 -1
  113. package/dist/components/kitchen-dossier.js +3 -3
  114. package/dist/components/label.js +2 -2
  115. package/dist/components/lazy-markdown.js +1 -1
  116. package/dist/components/link-button.js +2 -2
  117. package/dist/components/markdown-renderer.js +1 -1
  118. package/dist/components/menu-dead-end.d.ts +2 -1
  119. package/dist/components/menu-dead-end.js +1 -1
  120. package/dist/components/menu-family-block.d.ts +2 -1
  121. package/dist/components/menu-family-block.js +14 -8
  122. package/dist/components/menu-family-card.d.ts +2 -1
  123. package/dist/components/menu-family-card.js +1 -1
  124. package/dist/components/menu-field.js +1 -1
  125. package/dist/components/menu-filter-chips.d.ts +2 -1
  126. package/dist/components/menu-filter-chips.js +3 -3
  127. package/dist/components/menu-group-rows.d.ts +2 -1
  128. package/dist/components/menu-group-rows.js +2 -2
  129. package/dist/components/menu-hub-hero.js +1 -1
  130. package/dist/components/menu-index-header.d.ts +2 -1
  131. package/dist/components/menu-index-header.js +2 -2
  132. package/dist/components/menu-index-list.d.ts +9 -8
  133. package/dist/components/menu-index-list.js +4 -4
  134. package/dist/components/menu-intent-hero.d.ts +3 -2
  135. package/dist/components/menu-intent-hero.js +1 -1
  136. package/dist/components/menu-item.d.ts +2 -1
  137. package/dist/components/menu-item.js +4 -4
  138. package/dist/components/menu-kitchen-list.d.ts +2 -1
  139. package/dist/components/menu-kitchen-list.js +4 -4
  140. package/dist/components/menu-mocks.d.ts +16 -5
  141. package/dist/components/menu-mocks.js +23 -2
  142. package/dist/components/menu-types.d.ts +21 -34
  143. package/dist/components/motion-icon-actions.d.ts +27 -0
  144. package/dist/components/motion-icon-actions.js +336 -0
  145. package/dist/components/motion-icon-controls.js +3 -3
  146. package/dist/components/motion-icon-set.d.ts +13 -1
  147. package/dist/components/motion-icon-set.js +197 -6
  148. package/dist/components/motion-icon.d.ts +15 -1
  149. package/dist/components/motion-icon.js +2 -2
  150. package/dist/components/multi-select-combobox.js +7 -7
  151. package/dist/components/multi-select-menu.js +2 -2
  152. package/dist/components/native-select.js +1 -1
  153. package/dist/components/open-now-list.d.ts +2 -1
  154. package/dist/components/open-now-list.js +2 -2
  155. package/dist/components/pagination.js +2 -2
  156. package/dist/components/phone-frame.js +1 -1
  157. package/dist/components/photo-ground.js +1 -1
  158. package/dist/components/place-about-tab.d.ts +2 -1
  159. package/dist/components/place-about-tab.js +6 -6
  160. package/dist/components/place-card.d.ts +94 -12
  161. package/dist/components/place-card.js +203 -128
  162. package/dist/components/place-details-mobile.d.ts +2 -1
  163. package/dist/components/place-details-mobile.js +7 -7
  164. package/dist/components/place-schedule-week.d.ts +2 -1
  165. package/dist/components/place-schedule-week.js +3 -3
  166. package/dist/components/place-types.d.ts +3 -265
  167. package/dist/components/popover.js +2 -2
  168. package/dist/components/poster-stack.js +1 -1
  169. package/dist/components/poster-tape.js +1 -1
  170. package/dist/components/poster-wall.d.ts +22 -6
  171. package/dist/components/poster-wall.js +197 -12
  172. package/dist/components/progress.js +1 -1
  173. package/dist/components/qa-block.d.ts +2 -1
  174. package/dist/components/qa-block.js +1 -1
  175. package/dist/components/quiet-stretch.js +1 -1
  176. package/dist/components/radio-group.js +1 -1
  177. package/dist/components/rating.js +1 -1
  178. package/dist/components/scroll-area.js +1 -1
  179. package/dist/components/scroll-fade.js +2 -2
  180. package/dist/components/search-bar.js +1 -1
  181. package/dist/components/searchable-select.js +5 -5
  182. package/dist/components/section-header.js +2 -2
  183. package/dist/components/segmented-nav.d.ts +24 -4
  184. package/dist/components/segmented-nav.js +11 -2
  185. package/dist/components/select.js +1 -1
  186. package/dist/components/selection-bar.js +1 -1
  187. package/dist/components/separator.js +2 -2
  188. package/dist/components/sheet.js +1 -1
  189. package/dist/components/signature-chip.js +3 -3
  190. package/dist/components/skeleton-card.js +3 -3
  191. package/dist/components/skeleton.js +2 -2
  192. package/dist/components/slider.js +1 -1
  193. package/dist/components/sliding-panels.js +2 -2
  194. package/dist/components/snap-rail.d.ts +0 -5
  195. package/dist/components/snap-rail.js +2 -2
  196. package/dist/components/sonner.js +1 -1
  197. package/dist/components/sort-option-list.js +1 -1
  198. package/dist/components/spinner.d.ts +2 -2
  199. package/dist/components/spinner.js +1 -1
  200. package/dist/components/stat-banner.js +1 -1
  201. package/dist/components/stepper.js +1 -1
  202. package/dist/components/sticky-container.js +1 -1
  203. package/dist/components/story.js +1 -1
  204. package/dist/components/switch.js +1 -1
  205. package/dist/components/table.js +1 -1
  206. package/dist/components/tabs.js +2 -2
  207. package/dist/components/tag-group.js +2 -2
  208. package/dist/components/tag.d.ts +2 -2
  209. package/dist/components/tag.js +2 -2
  210. package/dist/components/text.d.ts +3 -3
  211. package/dist/components/text.js +2 -2
  212. package/dist/components/textarea.js +1 -1
  213. package/dist/components/timeline-band.js +4 -4
  214. package/dist/components/timeline-playhead.js +4 -4
  215. package/dist/components/toggle-group.js +2 -2
  216. package/dist/components/toggle.js +2 -2
  217. package/dist/components/tooltip.js +1 -1
  218. package/dist/components/top-comparison.d.ts +2 -1
  219. package/dist/components/top-comparison.js +1 -1
  220. package/dist/components/top-cross-link.d.ts +2 -1
  221. package/dist/components/top-cross-link.js +1 -1
  222. package/dist/components/top-ranking-entry.d.ts +2 -1
  223. package/dist/components/top-ranking-entry.js +6 -6
  224. package/dist/components/top-ranking-hero.d.ts +2 -1
  225. package/dist/components/top-ranking-hero.js +1 -1
  226. package/dist/components/top-ranking-legend.js +1 -1
  227. package/dist/components/top-types.d.ts +3 -1
  228. package/dist/components/use-mobile.js +1 -1
  229. package/dist/components/venue-events-block.js +5 -5
  230. package/dist/components/venue-grade-dimensions.d.ts +2 -1
  231. package/dist/components/venue-grade-dimensions.js +1 -1
  232. package/dist/components/venue-quotes.d.ts +2 -1
  233. package/dist/components/venue-quotes.js +1 -1
  234. package/dist/components/venue-wall.d.ts +2 -1
  235. package/dist/components/venue-wall.js +1 -1
  236. package/dist/components/weekday-theme-banner.js +1 -1
  237. package/dist/components/weekend-panel.js +4 -4
  238. package/dist/hooks/use-drag-swipe.js +1 -1
  239. package/dist/hooks/use-haptic.js +1 -1
  240. package/dist/hooks/use-media-query.js +1 -1
  241. package/dist/hooks/use-mobile.js +1 -1
  242. package/dist/hooks/use-mounted-theme.js +1 -1
  243. package/dist/hooks/use-sticky.js +1 -1
  244. package/dist/lib/card-hover.js +2 -18
  245. package/dist/lib/grade.d.ts +54 -0
  246. package/dist/lib/grade.js +2 -0
  247. package/dist/lib/icon-depth.d.ts +35 -0
  248. package/dist/lib/icon-depth.js +2 -0
  249. package/dist/lib/icon-registry.js +1 -1
  250. package/dist/lib/motion.js +2 -2
  251. package/dist/lib/overlay-pill.d.ts +30 -0
  252. package/dist/lib/overlay-pill.js +2 -0
  253. package/dist/lib/utils.js +1 -1
  254. package/dist/place-types-BHdeMMiG.d.ts +317 -0
  255. package/package.json +1 -1
  256. package/src/components/colour-dictionary.stories.tsx +268 -82
  257. package/src/components/doorway-tiles.stories.tsx +83 -0
  258. package/src/components/doorway-tiles.test.tsx +51 -0
  259. package/src/components/doorway-tiles.tsx +225 -0
  260. package/src/components/grade-atom.stories.tsx +81 -0
  261. package/src/components/grade-atom.tsx +256 -0
  262. package/src/components/hue-chip.stories.tsx +2 -2
  263. package/src/components/menu-family-block.stories.tsx +29 -0
  264. package/src/components/menu-family-block.test.tsx +51 -0
  265. package/src/components/menu-family-block.tsx +20 -4
  266. package/src/components/menu-index-list.tsx +7 -7
  267. package/src/components/menu-intent-hero.tsx +1 -1
  268. package/src/components/menu-kitchen-list.tsx +4 -1
  269. package/src/components/menu-mocks.ts +39 -3
  270. package/src/components/menu-types.ts +17 -33
  271. package/src/components/motion-icon-actions.test.tsx +115 -0
  272. package/src/components/motion-icon-actions.tsx +580 -0
  273. package/src/components/motion-icon-controls.test.tsx +1 -0
  274. package/src/components/motion-icon-controls.tsx +18 -13
  275. package/src/components/motion-icon-set.stories.tsx +62 -3
  276. package/src/components/motion-icon-set.tsx +349 -2
  277. package/src/components/motion-icon.test.tsx +180 -1
  278. package/src/components/motion-icon.tsx +97 -8
  279. package/src/components/place-card.stories.tsx +66 -76
  280. package/src/components/place-card.test.tsx +188 -100
  281. package/src/components/place-card.tsx +356 -148
  282. package/src/components/place-types.ts +17 -4
  283. package/src/components/poster-wall.stories.tsx +84 -0
  284. package/src/components/poster-wall.test.tsx +94 -3
  285. package/src/components/poster-wall.tsx +257 -9
  286. package/src/components/segmented-nav.stories.tsx +72 -1
  287. package/src/components/segmented-nav.test.tsx +96 -0
  288. package/src/components/segmented-nav.tsx +36 -4
  289. package/src/components/snap-rail.tsx +19 -0
  290. package/src/globals.css +26 -0
  291. package/src/lib/grade.test.ts +82 -0
  292. package/src/lib/grade.ts +76 -0
  293. package/src/lib/icon-depth.ts +35 -0
  294. package/src/lib/overlay-pill.ts +29 -0
  295. package/dist/components/menu-family-tiles.d.ts +0 -19
  296. package/dist/components/menu-family-tiles.js +0 -57
  297. package/src/components/menu-family-tiles.stories.tsx +0 -54
  298. package/src/components/menu-family-tiles.test.tsx +0 -30
  299. package/src/components/menu-family-tiles.tsx +0 -132
@@ -1,16 +1,26 @@
1
1
  import { describe, it, expect, vi } from "vitest";
2
2
  import { render, screen, fireEvent } from "@testing-library/react";
3
- import { PlaceCard } from "./place-card";
3
+ import { PlaceCard, PlaceCardSkeleton } from "./place-card";
4
4
  import type { PlaceCardLabels, PlacePreview } from "./place-types";
5
5
 
6
6
  // ─── Fixtures ─────────────────────────────────────────────────────────────────
7
7
 
8
8
  const LABELS: PlaceCardLabels = {
9
- open: "Open",
10
9
  closed: "Closed",
11
- until: "until",
12
- opensAt: "opens at",
13
- currency: "RON",
10
+ viewDetails: (name) => `View details for ${name}`,
11
+ priceLevel: (level) => `Price level ${level} of 4`,
12
+ grade: {
13
+ soft: "Worth exploring",
14
+ bands: {
15
+ exceptional: "Exceptional",
16
+ great: "Great",
17
+ good: "Good",
18
+ decent: "Decent",
19
+ weak: "Weak",
20
+ },
21
+ aria: (value) => `Grade ${value} out of 10`,
22
+ capped: "Capped",
23
+ },
14
24
  };
15
25
 
16
26
  function makePlace(overrides: Partial<PlacePreview> = {}): PlacePreview {
@@ -25,163 +35,241 @@ function makePlace(overrides: Partial<PlacePreview> = {}): PlacePreview {
25
35
  // ─── Tests ────────────────────────────────────────────────────────────────────
26
36
 
27
37
  describe("PlaceCard", () => {
28
- // ─── Happy path ─────────────────────────────────────────────────────────────
38
+ it("renders the name and an accessible card label", () => {
39
+ render(<PlaceCard place={makePlace()} labels={LABELS} onOpen={vi.fn()} />);
40
+ expect(screen.getByText("Test Place")).toBeInTheDocument();
41
+ expect(screen.getByRole("button", { name: "View details for Test Place" })).toBeInTheDocument();
42
+ });
29
43
 
30
- it("renders place name", () => {
31
- render(<PlaceCard place={makePlace()} labels={LABELS} />);
44
+ it("is a plain block — no role, no tab stop — without href or onOpen", () => {
45
+ const { container } = render(<PlaceCard place={makePlace()} labels={LABELS} />);
32
46
  expect(screen.getByText("Test Place")).toBeInTheDocument();
47
+ expect(screen.queryByRole("button")).not.toBeInTheDocument();
48
+ expect(screen.queryByRole("link")).not.toBeInTheDocument();
49
+ expect(container.querySelector("[tabindex]")).not.toBeInTheDocument();
50
+ expect(container.querySelector(".cursor-pointer")).not.toBeInTheDocument();
33
51
  });
34
52
 
35
- it("renders rating when provided", () => {
53
+ // ─── Headline metric ────────────────────────────────────────────────────────
54
+
55
+ it("shows the grade over the star when the place has one", () => {
36
56
  render(
37
57
  <PlaceCard
38
- place={makePlace({ rating: { rating: 4.5, count: 120 } })}
58
+ place={makePlace({
59
+ rating: { rating: 4.5, count: 120 },
60
+ reviews_digest: { grade: { overall: 8.2 } },
61
+ })}
39
62
  labels={LABELS}
40
63
  />
41
64
  );
42
- expect(screen.getByText("4.5")).toBeInTheDocument();
43
- expect(screen.getByText("(120)")).toBeInTheDocument();
65
+ expect(screen.getByLabelText("Grade 8.2 out of 10")).toBeInTheDocument();
66
+ expect(screen.queryByText("4.5")).not.toBeInTheDocument();
44
67
  });
45
68
 
46
- it("does not render rating when omitted", () => {
47
- render(<PlaceCard place={makePlace()} labels={LABELS} />);
48
- // No star-rating text should appear
49
- expect(screen.queryByText(/^\d+\.\d+$/)).not.toBeInTheDocument();
69
+ it("falls back to the star rating when there is no grade", () => {
70
+ render(<PlaceCard place={makePlace({ rating: { rating: 4.5, count: 120 } })} labels={LABELS} />);
71
+ expect(screen.getByText("4.5")).toBeInTheDocument();
50
72
  });
51
73
 
52
- // ─── onClick / interactive behaviour ────────────────────────────────────────
53
-
54
- it("renders a <button> with cursor-pointer class when onClick is provided", () => {
55
- const onClick = vi.fn();
56
- render(<PlaceCard place={makePlace()} labels={LABELS} onClick={onClick} />);
57
- const button = screen.getByRole("button");
58
- expect(button).toBeInTheDocument();
59
- expect(button.className).toContain("cursor-pointer");
74
+ it("falls back to the star when the grade is soft — the xs slot has no room for the sentence", () => {
75
+ render(
76
+ <PlaceCard
77
+ place={makePlace({
78
+ rating: { rating: 4.5, count: 120 },
79
+ reviews_digest: { grade: { overall: 5.1, show_number: false } },
80
+ })}
81
+ labels={LABELS}
82
+ />
83
+ );
84
+ expect(screen.getByText("4.5")).toBeInTheDocument();
85
+ expect(screen.queryByText("Worth exploring")).not.toBeInTheDocument();
60
86
  });
61
87
 
62
- it("renders a <div> (no button role) when onClick is omitted", () => {
88
+ it("renders nothing in the metric slot without rating or grade", () => {
63
89
  render(<PlaceCard place={makePlace()} labels={LABELS} />);
64
- expect(screen.queryByRole("button")).not.toBeInTheDocument();
65
- });
66
-
67
- it("fires onClick with the place object when clicked", () => {
68
- const onClick = vi.fn();
69
- const place = makePlace({ name: "Clickable Place" });
70
- render(<PlaceCard place={place} labels={LABELS} onClick={onClick} />);
71
- fireEvent.click(screen.getByRole("button"));
72
- expect(onClick).toHaveBeenCalledTimes(1);
73
- expect(onClick).toHaveBeenCalledWith(place);
90
+ expect(screen.queryByText(/^\d+\.\d+$/)).not.toBeInTheDocument();
74
91
  });
75
92
 
76
- // ─── Thumbnail fallback ──────────────────────────────────────────────────────
77
-
78
- it("renders UtensilsCrossed icon when thumbnail_url is missing", () => {
79
- render(<PlaceCard place={makePlace()} labels={LABELS} />);
80
- // The icon is aria-hidden but we can check that no <img> is rendered
81
- expect(screen.queryByRole("img")).not.toBeInTheDocument();
82
- // Verify the container still renders (icon placeholder)
83
- const heading = screen.getByText("Test Place");
84
- expect(heading).toBeInTheDocument();
85
- });
93
+ // ─── Meta rows ──────────────────────────────────────────────────────────────
86
94
 
87
- it("renders image when thumbnail_url is provided", () => {
95
+ it("renders the first cuisine and the price level", () => {
88
96
  render(
89
97
  <PlaceCard
90
- place={makePlace({ thumbnail_url: "https://example.com/thumb.jpg" })}
98
+ place={makePlace({ cuisines: ["italian", "pizza"], price_dishes: { price_score: 2 } })}
91
99
  labels={LABELS}
92
100
  />
93
101
  );
94
- expect(screen.getByRole("img")).toBeInTheDocument();
102
+ expect(screen.getByText("italian")).toBeInTheDocument();
103
+ expect(screen.queryByText("pizza")).not.toBeInTheDocument();
104
+ expect(screen.getByLabelText("Price level 2 of 4")).toBeInTheDocument();
105
+ });
106
+
107
+ it("clamps an out-of-range price level instead of throwing", () => {
108
+ render(<PlaceCard place={makePlace({ price_drinks: { price_score: 7 } })} labels={LABELS} />);
109
+ expect(screen.getByLabelText("Price level 7 of 4")).toHaveTextContent("$$$$");
95
110
  });
96
111
 
97
- it("falls back to UtensilsCrossed icon when image errors", () => {
112
+ it("prefers distance over the neighbourhood once it is known", () => {
98
113
  render(
99
114
  <PlaceCard
100
- place={makePlace({ thumbnail_url: "https://example.com/thumb.jpg" })}
115
+ place={makePlace({
116
+ areas: [{ name: "Centru" }],
117
+ location: { distance: { meters: 1240 } },
118
+ })}
101
119
  labels={LABELS}
102
120
  />
103
121
  );
104
- const img = screen.getByRole("img");
105
- expect(img).toBeInTheDocument();
106
-
107
- // Trigger the error handler
108
- fireEvent.error(img);
109
-
110
- // Image should be replaced by the fallback (no <img> in DOM)
111
- expect(screen.queryByRole("img")).not.toBeInTheDocument();
122
+ expect(screen.getByText("1.2 km")).toBeInTheDocument();
123
+ expect(screen.queryByText("Centru")).not.toBeInTheDocument();
112
124
  });
113
125
 
114
- // ─── Open / Closed status labels ────────────────────────────────────────────
126
+ it("shows the neighbourhood without geolocation, and metres under a kilometre", () => {
127
+ const { rerender } = render(
128
+ <PlaceCard place={makePlace({ areas: [{ name: "Centru" }] })} labels={LABELS} />
129
+ );
130
+ expect(screen.getByText("Centru")).toBeInTheDocument();
115
131
 
116
- it("shows labels.open text when place.open_now is true", () => {
117
- render(
118
- <PlaceCard place={makePlace({ open_now: true })} labels={LABELS} />
132
+ rerender(
133
+ <PlaceCard place={makePlace({ location: { distance: { meters: 340 } } })} labels={LABELS} />
119
134
  );
120
- expect(screen.getByText("Open")).toBeInTheDocument();
135
+ expect(screen.getByText("340 m")).toBeInTheDocument();
121
136
  });
122
137
 
123
- it("shows labels.closed text when place.open_now is false", () => {
124
- render(
138
+ it("shows the closed pill only when open_now is explicitly false", () => {
139
+ const { rerender } = render(
125
140
  <PlaceCard place={makePlace({ open_now: false })} labels={LABELS} />
126
141
  );
127
142
  expect(screen.getByText("Closed")).toBeInTheDocument();
143
+
144
+ rerender(<PlaceCard place={makePlace()} labels={LABELS} />);
145
+ expect(screen.queryByText("Closed")).not.toBeInTheDocument();
146
+ });
147
+
148
+ it("renders the footer under a separator", () => {
149
+ const { container } = render(
150
+ <PlaceCard place={makePlace()} labels={LABELS} footer={<span>Served as X</span>} />
151
+ );
152
+ expect(screen.getByText("Served as X")).toBeInTheDocument();
153
+ expect(container.querySelector('[data-slot="separator"]')).toBeInTheDocument();
154
+ });
155
+
156
+ // ─── Cover image ────────────────────────────────────────────────────────────
157
+
158
+ it("renders the placeholder without a cover, and after the cover fails", () => {
159
+ const { rerender } = render(<PlaceCard place={makePlace()} labels={LABELS} />);
160
+ expect(screen.queryByRole("img")).not.toBeInTheDocument();
161
+
162
+ rerender(
163
+ <PlaceCard place={makePlace({ cover_url: "https://example.com/cover.jpg" })} labels={LABELS} />
164
+ );
165
+ const img = screen.getByRole("img");
166
+ expect(img).toHaveAttribute("alt", "Test Place");
167
+ fireEvent.error(img);
168
+ expect(screen.queryByRole("img")).not.toBeInTheDocument();
128
169
  });
129
170
 
130
- it("uses labels.until with closes_at hour when open_now is true", () => {
171
+ it("routes the cover through an injected image component with the sizes and priority hints", () => {
172
+ const ImageComponent = vi.fn(() => <span data-testid="injected-image" />);
131
173
  render(
132
174
  <PlaceCard
133
- place={makePlace({
134
- open_now: true,
135
- today_schedule: [{ opens_at: 9, closes_at: 22 }],
136
- })}
175
+ place={makePlace({ cover_url: "place/1/cover.avif" })}
137
176
  labels={LABELS}
177
+ imageComponent={ImageComponent}
178
+ imageSizes="220px"
179
+ priority
138
180
  />
139
181
  );
140
- // Renders "· until 22:00"
141
- expect(screen.getByText(/until 22:00/)).toBeInTheDocument();
182
+ expect(screen.getByTestId("injected-image")).toBeInTheDocument();
183
+ expect(ImageComponent).toHaveBeenCalledWith(
184
+ expect.objectContaining({
185
+ src: "place/1/cover.avif",
186
+ alt: "Test Place",
187
+ sizes: "220px",
188
+ priority: true,
189
+ }),
190
+ undefined
191
+ );
142
192
  });
143
193
 
144
- it("uses labels.opensAt with opens_at hour when open_now is false", () => {
194
+ // ─── Interaction ────────────────────────────────────────────────────────────
195
+
196
+ it("opens on click, Enter and Space without an href", () => {
197
+ const onOpen = vi.fn();
198
+ const place = makePlace();
199
+ render(<PlaceCard place={place} labels={LABELS} onOpen={onOpen} />);
200
+ const root = screen.getByRole("button");
201
+ fireEvent.click(root);
202
+ fireEvent.keyDown(root, { key: "Enter" });
203
+ fireEvent.keyDown(root, { key: " " });
204
+ expect(onOpen).toHaveBeenCalledTimes(3);
205
+ expect(onOpen).toHaveBeenCalledWith(place);
206
+ });
207
+
208
+ it("renders a real anchor for an href and intercepts only plain left-clicks", () => {
209
+ const onOpen = vi.fn();
145
210
  render(
146
- <PlaceCard
147
- place={makePlace({
148
- open_now: false,
149
- today_schedule: [{ opens_at: 9, closes_at: 22 }],
150
- })}
151
- labels={LABELS}
152
- />
211
+ <PlaceCard place={makePlace()} labels={LABELS} href="/ro/cluj/places/test-place" onOpen={onOpen} />
153
212
  );
154
- expect(screen.getByText(/opens at 09:00/)).toBeInTheDocument();
213
+ const link = screen.getByRole("link", { name: "View details for Test Place" });
214
+ expect(link).toHaveAttribute("href", "/ro/cluj/places/test-place");
215
+
216
+ const plain = fireEvent.click(link);
217
+ expect(plain).toBe(false);
218
+ expect(onOpen).toHaveBeenCalledTimes(1);
219
+
220
+ const modified = fireEvent.click(link, { metaKey: true });
221
+ expect(modified).toBe(true);
222
+ expect(onOpen).toHaveBeenCalledTimes(1);
155
223
  });
156
224
 
157
- // ─── Schedule priority: today_schedule[0] over hours[0] ─────────────────────
225
+ it("keeps the linked root mounted across re-renders (motion wrapper is memoised)", () => {
226
+ const place = makePlace({ cover_url: "https://example.com/cover.jpg" });
227
+ const { rerender } = render(<PlaceCard place={place} labels={LABELS} href="/x" />);
228
+ const anchor = screen.getByRole("link");
229
+ const img = screen.getByRole("img");
158
230
 
159
- it("prefers today_schedule[0] over hours[0] for schedule display", () => {
231
+ // A fresh labels object forces a render; an un-memoised `motion.create`
232
+ // would give the root a new element type and React would remount both.
233
+ rerender(<PlaceCard place={place} labels={{ ...LABELS }} href="/x" />);
234
+ expect(screen.getByRole("link")).toBe(anchor);
235
+ expect(screen.getByRole("img")).toBe(img);
236
+ });
237
+
238
+ it("uses the injected link component", () => {
239
+ const LinkComponent = vi.fn(({ href, children }: { href: string; children: React.ReactNode }) => (
240
+ <a href={href} data-testid="injected-link">
241
+ {children}
242
+ </a>
243
+ ));
160
244
  render(
161
- <PlaceCard
162
- place={makePlace({
163
- open_now: false,
164
- today_schedule: [{ opens_at: 11, closes_at: 23 }],
165
- hours: [{ opens_at: 9, closes_at: 22 }],
166
- })}
167
- labels={LABELS}
168
- />
245
+ <PlaceCard place={makePlace()} labels={LABELS} href="/x" linkComponent={LinkComponent} />
169
246
  );
170
- // today_schedule says opens at 11, hours says 9 — expect 11
171
- expect(screen.getByText(/opens at 11:00/)).toBeInTheDocument();
172
- expect(screen.queryByText(/09:00/)).not.toBeInTheDocument();
247
+ expect(screen.getByTestId("injected-link")).toHaveAttribute("href", "/x");
173
248
  });
174
249
 
175
- it("falls back to hours[0] when today_schedule is absent", () => {
250
+ it("keeps the photo overlay outside the interactive root", () => {
176
251
  render(
177
252
  <PlaceCard
178
- place={makePlace({
179
- open_now: false,
180
- hours: [{ opens_at: 9, closes_at: 22 }],
181
- })}
253
+ place={makePlace()}
182
254
  labels={LABELS}
255
+ onOpen={vi.fn()}
256
+ photoOverlay={<button type="button">Trending</button>}
183
257
  />
184
258
  );
185
- expect(screen.getByText(/opens at 09:00/)).toBeInTheDocument();
259
+ const overlay = screen.getByRole("button", { name: "Trending" });
260
+ const card = screen.getByRole("button", { name: "View details for Test Place" });
261
+ expect(card).not.toContainElement(overlay);
262
+ });
263
+ });
264
+
265
+ describe("PlaceCardSkeleton", () => {
266
+ it("is hidden from assistive tech and reserves the footer when asked", () => {
267
+ const { container, rerender } = render(<PlaceCardSkeleton />);
268
+ expect(container.firstChild).toHaveAttribute("aria-hidden", "true");
269
+ expect(container.querySelector('[data-slot="separator"]')).not.toBeInTheDocument();
270
+
271
+ rerender(<PlaceCardSkeleton footer={<span data-testid="footer-skeleton" />} />);
272
+ expect(container.querySelector('[data-slot="separator"]')).toBeInTheDocument();
273
+ expect(screen.getByTestId("footer-skeleton")).toBeInTheDocument();
186
274
  });
187
275
  });