@oxy.so/bloom 2.11.0 → 2.12.0

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 (180) hide show
  1. package/docs/app-shell.mdx +90 -4
  2. package/docs/content-panel.mdx +65 -19
  3. package/docs/date-picker.mdx +80 -2
  4. package/docs/listing-card.mdx +54 -0
  5. package/docs/rating.mdx +52 -2
  6. package/docs/stay-search.mdx +38 -0
  7. package/lib/commonjs/app-shell/AppShell.js +121 -5
  8. package/lib/commonjs/app-shell/AppShell.js.map +1 -1
  9. package/lib/commonjs/app-shell/layout.js +10 -5
  10. package/lib/commonjs/app-shell/layout.js.map +1 -1
  11. package/lib/commonjs/content-panel/ContentPanel.js +8 -2
  12. package/lib/commonjs/content-panel/ContentPanel.js.map +1 -1
  13. package/lib/commonjs/content-panel/ContentPanel.web.js +38 -25
  14. package/lib/commonjs/content-panel/ContentPanel.web.js.map +1 -1
  15. package/lib/commonjs/date-picker/TimeField.js +175 -0
  16. package/lib/commonjs/date-picker/TimeField.js.map +1 -0
  17. package/lib/commonjs/date-picker/index.js +7 -0
  18. package/lib/commonjs/date-picker/index.js.map +1 -1
  19. package/lib/commonjs/date-picker/time.js +160 -0
  20. package/lib/commonjs/date-picker/time.js.map +1 -0
  21. package/lib/commonjs/index.js +7 -0
  22. package/lib/commonjs/index.js.map +1 -1
  23. package/lib/commonjs/index.web.js +7 -0
  24. package/lib/commonjs/index.web.js.map +1 -1
  25. package/lib/commonjs/listing-card/ListingCard.js +34 -1
  26. package/lib/commonjs/listing-card/ListingCard.js.map +1 -1
  27. package/lib/commonjs/listing-card/WishlistCard.js +50 -8
  28. package/lib/commonjs/listing-card/WishlistCard.js.map +1 -1
  29. package/lib/commonjs/listing-card/shared.js +23 -1
  30. package/lib/commonjs/listing-card/shared.js.map +1 -1
  31. package/lib/commonjs/rating/RatingInput.js +218 -0
  32. package/lib/commonjs/rating/RatingInput.js.map +1 -0
  33. package/lib/commonjs/rating/index.js +7 -0
  34. package/lib/commonjs/rating/index.js.map +1 -1
  35. package/lib/commonjs/stay-search/DestinationSuggestions.js +46 -9
  36. package/lib/commonjs/stay-search/DestinationSuggestions.js.map +1 -1
  37. package/lib/commonjs/stay-search/GuestPicker.js +9 -0
  38. package/lib/commonjs/stay-search/GuestPicker.js.map +1 -1
  39. package/lib/commonjs/track-list/parts.js +0 -13
  40. package/lib/commonjs/track-list/parts.js.map +1 -1
  41. package/lib/module/app-shell/AppShell.js +121 -5
  42. package/lib/module/app-shell/AppShell.js.map +1 -1
  43. package/lib/module/app-shell/layout.js +10 -5
  44. package/lib/module/app-shell/layout.js.map +1 -1
  45. package/lib/module/content-panel/ContentPanel.js +8 -2
  46. package/lib/module/content-panel/ContentPanel.js.map +1 -1
  47. package/lib/module/content-panel/ContentPanel.web.js +38 -25
  48. package/lib/module/content-panel/ContentPanel.web.js.map +1 -1
  49. package/lib/module/date-picker/TimeField.js +170 -0
  50. package/lib/module/date-picker/TimeField.js.map +1 -0
  51. package/lib/module/date-picker/index.js +3 -0
  52. package/lib/module/date-picker/index.js.map +1 -1
  53. package/lib/module/date-picker/time.js +151 -0
  54. package/lib/module/date-picker/time.js.map +1 -0
  55. package/lib/module/index.js +1 -1
  56. package/lib/module/index.js.map +1 -1
  57. package/lib/module/index.web.js +1 -1
  58. package/lib/module/index.web.js.map +1 -1
  59. package/lib/module/listing-card/ListingCard.js +34 -1
  60. package/lib/module/listing-card/ListingCard.js.map +1 -1
  61. package/lib/module/listing-card/WishlistCard.js +50 -8
  62. package/lib/module/listing-card/WishlistCard.js.map +1 -1
  63. package/lib/module/listing-card/shared.js +22 -0
  64. package/lib/module/listing-card/shared.js.map +1 -1
  65. package/lib/module/rating/RatingInput.js +212 -0
  66. package/lib/module/rating/RatingInput.js.map +1 -0
  67. package/lib/module/rating/index.js +1 -0
  68. package/lib/module/rating/index.js.map +1 -1
  69. package/lib/module/stay-search/DestinationSuggestions.js +45 -9
  70. package/lib/module/stay-search/DestinationSuggestions.js.map +1 -1
  71. package/lib/module/stay-search/GuestPicker.js +9 -0
  72. package/lib/module/stay-search/GuestPicker.js.map +1 -1
  73. package/lib/module/track-list/parts.js +1 -14
  74. package/lib/module/track-list/parts.js.map +1 -1
  75. package/lib/typescript/commonjs/app-shell/AppShell.d.ts.map +1 -1
  76. package/lib/typescript/commonjs/app-shell/layout.d.ts +5 -4
  77. package/lib/typescript/commonjs/app-shell/layout.d.ts.map +1 -1
  78. package/lib/typescript/commonjs/app-shell/types.d.ts +12 -2
  79. package/lib/typescript/commonjs/app-shell/types.d.ts.map +1 -1
  80. package/lib/typescript/commonjs/content-panel/ContentPanel.d.ts.map +1 -1
  81. package/lib/typescript/commonjs/content-panel/ContentPanel.web.d.ts.map +1 -1
  82. package/lib/typescript/commonjs/content-panel/types.d.ts +53 -42
  83. package/lib/typescript/commonjs/content-panel/types.d.ts.map +1 -1
  84. package/lib/typescript/commonjs/date-picker/TimeField.d.ts +6 -0
  85. package/lib/typescript/commonjs/date-picker/TimeField.d.ts.map +1 -0
  86. package/lib/typescript/commonjs/date-picker/index.d.ts +2 -1
  87. package/lib/typescript/commonjs/date-picker/index.d.ts.map +1 -1
  88. package/lib/typescript/commonjs/date-picker/time.d.ts +56 -0
  89. package/lib/typescript/commonjs/date-picker/time.d.ts.map +1 -0
  90. package/lib/typescript/commonjs/date-picker/types.d.ts +49 -3
  91. package/lib/typescript/commonjs/date-picker/types.d.ts.map +1 -1
  92. package/lib/typescript/commonjs/index.d.ts +2 -2
  93. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  94. package/lib/typescript/commonjs/index.web.d.ts +2 -2
  95. package/lib/typescript/commonjs/index.web.d.ts.map +1 -1
  96. package/lib/typescript/commonjs/listing-card/ListingCard.d.ts.map +1 -1
  97. package/lib/typescript/commonjs/listing-card/WishlistCard.d.ts +1 -1
  98. package/lib/typescript/commonjs/listing-card/WishlistCard.d.ts.map +1 -1
  99. package/lib/typescript/commonjs/listing-card/shared.d.ts +8 -1
  100. package/lib/typescript/commonjs/listing-card/shared.d.ts.map +1 -1
  101. package/lib/typescript/commonjs/listing-card/types.d.ts +37 -0
  102. package/lib/typescript/commonjs/listing-card/types.d.ts.map +1 -1
  103. package/lib/typescript/commonjs/rating/RatingInput.d.ts +8 -0
  104. package/lib/typescript/commonjs/rating/RatingInput.d.ts.map +1 -0
  105. package/lib/typescript/commonjs/rating/index.d.ts +2 -1
  106. package/lib/typescript/commonjs/rating/index.d.ts.map +1 -1
  107. package/lib/typescript/commonjs/rating/types.d.ts +31 -0
  108. package/lib/typescript/commonjs/rating/types.d.ts.map +1 -1
  109. package/lib/typescript/commonjs/stay-search/DestinationSuggestions.d.ts +8 -0
  110. package/lib/typescript/commonjs/stay-search/DestinationSuggestions.d.ts.map +1 -1
  111. package/lib/typescript/commonjs/stay-search/GuestPicker.d.ts +6 -1
  112. package/lib/typescript/commonjs/stay-search/GuestPicker.d.ts.map +1 -1
  113. package/lib/typescript/commonjs/stay-search/types.d.ts +20 -0
  114. package/lib/typescript/commonjs/stay-search/types.d.ts.map +1 -1
  115. package/lib/typescript/commonjs/track-list/parts.d.ts.map +1 -1
  116. package/lib/typescript/module/app-shell/AppShell.d.ts.map +1 -1
  117. package/lib/typescript/module/app-shell/layout.d.ts +5 -4
  118. package/lib/typescript/module/app-shell/layout.d.ts.map +1 -1
  119. package/lib/typescript/module/app-shell/types.d.ts +12 -2
  120. package/lib/typescript/module/app-shell/types.d.ts.map +1 -1
  121. package/lib/typescript/module/content-panel/ContentPanel.d.ts.map +1 -1
  122. package/lib/typescript/module/content-panel/ContentPanel.web.d.ts.map +1 -1
  123. package/lib/typescript/module/content-panel/types.d.ts +53 -42
  124. package/lib/typescript/module/content-panel/types.d.ts.map +1 -1
  125. package/lib/typescript/module/date-picker/TimeField.d.ts +6 -0
  126. package/lib/typescript/module/date-picker/TimeField.d.ts.map +1 -0
  127. package/lib/typescript/module/date-picker/index.d.ts +2 -1
  128. package/lib/typescript/module/date-picker/index.d.ts.map +1 -1
  129. package/lib/typescript/module/date-picker/time.d.ts +56 -0
  130. package/lib/typescript/module/date-picker/time.d.ts.map +1 -0
  131. package/lib/typescript/module/date-picker/types.d.ts +49 -3
  132. package/lib/typescript/module/date-picker/types.d.ts.map +1 -1
  133. package/lib/typescript/module/index.d.ts +2 -2
  134. package/lib/typescript/module/index.d.ts.map +1 -1
  135. package/lib/typescript/module/index.web.d.ts +2 -2
  136. package/lib/typescript/module/index.web.d.ts.map +1 -1
  137. package/lib/typescript/module/listing-card/ListingCard.d.ts.map +1 -1
  138. package/lib/typescript/module/listing-card/WishlistCard.d.ts +1 -1
  139. package/lib/typescript/module/listing-card/WishlistCard.d.ts.map +1 -1
  140. package/lib/typescript/module/listing-card/shared.d.ts +8 -1
  141. package/lib/typescript/module/listing-card/shared.d.ts.map +1 -1
  142. package/lib/typescript/module/listing-card/types.d.ts +37 -0
  143. package/lib/typescript/module/listing-card/types.d.ts.map +1 -1
  144. package/lib/typescript/module/rating/RatingInput.d.ts +8 -0
  145. package/lib/typescript/module/rating/RatingInput.d.ts.map +1 -0
  146. package/lib/typescript/module/rating/index.d.ts +2 -1
  147. package/lib/typescript/module/rating/index.d.ts.map +1 -1
  148. package/lib/typescript/module/rating/types.d.ts +31 -0
  149. package/lib/typescript/module/rating/types.d.ts.map +1 -1
  150. package/lib/typescript/module/stay-search/DestinationSuggestions.d.ts +8 -0
  151. package/lib/typescript/module/stay-search/DestinationSuggestions.d.ts.map +1 -1
  152. package/lib/typescript/module/stay-search/GuestPicker.d.ts +6 -1
  153. package/lib/typescript/module/stay-search/GuestPicker.d.ts.map +1 -1
  154. package/lib/typescript/module/stay-search/types.d.ts +20 -0
  155. package/lib/typescript/module/stay-search/types.d.ts.map +1 -1
  156. package/lib/typescript/module/track-list/parts.d.ts.map +1 -1
  157. package/package.json +1 -1
  158. package/src/app-shell/AppShell.tsx +91 -4
  159. package/src/app-shell/layout.ts +10 -5
  160. package/src/app-shell/types.ts +12 -2
  161. package/src/content-panel/ContentPanel.tsx +8 -2
  162. package/src/content-panel/ContentPanel.web.tsx +39 -25
  163. package/src/content-panel/types.ts +50 -42
  164. package/src/date-picker/TimeField.tsx +162 -0
  165. package/src/date-picker/index.ts +6 -0
  166. package/src/date-picker/time.ts +158 -0
  167. package/src/date-picker/types.ts +52 -3
  168. package/src/index.ts +9 -2
  169. package/src/index.web.ts +9 -2
  170. package/src/listing-card/ListingCard.tsx +34 -1
  171. package/src/listing-card/WishlistCard.tsx +42 -6
  172. package/src/listing-card/shared.ts +22 -0
  173. package/src/listing-card/types.ts +37 -0
  174. package/src/rating/RatingInput.tsx +214 -0
  175. package/src/rating/index.ts +9 -1
  176. package/src/rating/types.ts +33 -0
  177. package/src/stay-search/DestinationSuggestions.tsx +48 -9
  178. package/src/stay-search/GuestPicker.tsx +9 -0
  179. package/src/stay-search/types.ts +20 -0
  180. package/src/track-list/parts.tsx +1 -20
@@ -1,4 +1,4 @@
1
- import type { StyleProp, ViewStyle } from 'react-native';
1
+ import type { StyleProp, TextStyle, ViewStyle } from 'react-native';
2
2
 
3
3
  /** An inclusive range of calendar days. Both ends are local-midnight `Date`s. */
4
4
  export interface DateRange {
@@ -126,8 +126,14 @@ export interface MeetingSchedulerValue {
126
126
  time: string | null;
127
127
  }
128
128
 
129
- /** The hour-format toggle. */
130
- export type MeetingSchedulerHourFormat = '12h' | '24h';
129
+ /**
130
+ * How a time of day is drawn. The VALUE is always a 24h `"HH:mm"` string —
131
+ * this decides only what the reader sees.
132
+ */
133
+ export type HourFormat = '12h' | '24h';
134
+
135
+ /** The hour-format toggle. An alias of {@link HourFormat}. */
136
+ export type MeetingSchedulerHourFormat = HourFormat;
131
137
 
132
138
  /** Fixed strings, overridable for localisation. */
133
139
  export interface MeetingSchedulerLabels {
@@ -169,3 +175,46 @@ export interface MeetingSchedulerProps extends CalendarConstraintProps {
169
175
  style?: StyleProp<ViewStyle>;
170
176
  testID?: string;
171
177
  }
178
+
179
+ /** `small` is a 32-tall field with body-2 text; `medium` (default) 38 with body. */
180
+ export type TimeFieldSize = 'small' | 'medium';
181
+
182
+ export interface TimeFieldProps {
183
+ /**
184
+ * The time as a 24h `"HH:mm"` string, or `null` while the field is empty.
185
+ * Fully controlled. The same shape `MeetingScheduler` uses, so a value drops
186
+ * straight into one of its `timeSlots`.
187
+ */
188
+ value: string | null;
189
+ /**
190
+ * Called with the committed time — on blur, on submit, or on an arrow key.
191
+ * `null` when the field has been emptied. Never called while typing.
192
+ */
193
+ onChange: (value: string | null) => void;
194
+ /** Earliest time, 24h `"HH:mm"`. A typed time before it is pulled up to it. */
195
+ min?: string;
196
+ /** Latest time, 24h `"HH:mm"`. A typed time after it is pulled back to it. */
197
+ max?: string;
198
+ /**
199
+ * Minutes one arrow press moves, and the grid a typed time snaps to. Default
200
+ * `1` (no snapping); pass `15` or `30` for a booking grid. Counted from
201
+ * midnight, so 15 always means :00 :15 :30 :45.
202
+ */
203
+ step?: number;
204
+ /** How the time is DRAWN. Default `'24h'`; the value stays 24h either way. */
205
+ hourFormat?: HourFormat;
206
+ /** Default `medium`. */
207
+ size?: TimeFieldSize;
208
+ disabled?: boolean;
209
+ /**
210
+ * The NAME of the time ("Viewing time", "Check-in"). Required: the field
211
+ * draws no label of its own, and `"--:--"` names nothing.
212
+ */
213
+ accessibilityLabel: string;
214
+ /** The text while empty. Default `"--:--"`, or `"--:-- --"` in 12h. */
215
+ placeholder?: string;
216
+ /** Fixed width. Default: 104 at `medium`, 96 at `small`. */
217
+ width?: number;
218
+ style?: StyleProp<TextStyle>;
219
+ testID?: string;
220
+ }
package/src/index.ts CHANGED
@@ -292,8 +292,15 @@ export type { BottomSheetRef, BottomSheetProps } from './bottom-sheet';
292
292
  // Data display
293
293
  export * from './card';
294
294
  export * from './badge';
295
- export { Rating, RatingBar } from './rating';
296
- export type { RatingBarProps, RatingCountStyle, RatingProps, RatingSize } from './rating';
295
+ export { Rating, RatingBar, RatingInput } from './rating';
296
+ export type {
297
+ RatingBarProps,
298
+ RatingCountStyle,
299
+ RatingInputProps,
300
+ RatingInputSize,
301
+ RatingProps,
302
+ RatingSize,
303
+ } from './rating';
297
304
  export {
298
305
  ExplicitBadge,
299
306
  formatDuration,
package/src/index.web.ts CHANGED
@@ -297,8 +297,15 @@ export type { BottomSheetRef, BottomSheetProps } from './bottom-sheet/index.web'
297
297
  // Data display
298
298
  export * from './card';
299
299
  export * from './badge';
300
- export { Rating, RatingBar } from './rating';
301
- export type { RatingBarProps, RatingCountStyle, RatingProps, RatingSize } from './rating';
300
+ export { Rating, RatingBar, RatingInput } from './rating';
301
+ export type {
302
+ RatingBarProps,
303
+ RatingCountStyle,
304
+ RatingInputProps,
305
+ RatingInputSize,
306
+ RatingProps,
307
+ RatingSize,
308
+ } from './rating';
302
309
  export {
303
310
  ExplicitBadge,
304
311
  formatDuration,
@@ -48,6 +48,7 @@ import {
48
48
  LISTING_CARD_STYLE_ID,
49
49
  PHOTO_ASPECT_RATIO,
50
50
  PHOTO_RADIUS,
51
+ PHOTO_ZOOM_SCALE,
51
52
  resolveListingCardPaint,
52
53
  locationText,
53
54
  resolvePhoto,
@@ -91,6 +92,21 @@ import type { ListingCardLayout, ListingCardProps } from './types';
91
92
  *
92
93
  * The overlay repeats the photo's geometry (the same width share and aspect
93
94
  * ratio) rather than measuring it, so it is right on the first frame.
95
+ *
96
+ * `hoverZoom` scales the photo to {@link PHOTO_ZOOM_SCALE} while a pointer is
97
+ * over the card — a CSS transform on the photo inside the clipping tile, WEB
98
+ * ONLY (`@media (any-hover: hover)`, so a touch screen that reports a hover it
99
+ * cannot deliver is excluded) and nothing at all under reduced motion. It is
100
+ * off by default: a card that grows under the pointer is a decision an app
101
+ * makes, not one Bloom makes for every app that already ships this card. The
102
+ * compact density ignores it — a 112px thumbnail has nothing to come forward
103
+ * from.
104
+ *
105
+ * `onLongPress` and `onContextMenu` are the shortcut to a sheet or a menu, and
106
+ * they carry `MessageBubble`'s contract verbatim: a long press on native (and
107
+ * on a web touch screen), right-click on web with the event defaulted. Neither
108
+ * has a keyboard spelling, so whatever they reach must be reachable another
109
+ * way.
94
110
  */
95
111
 
96
112
  const TEXT_GAP = 12;
@@ -198,6 +214,7 @@ function PhotoTrack({
198
214
  source={{ uri }}
199
215
  resizeMode="cover"
200
216
  accessibilityIgnoresInvertColors
217
+ {...webDataSet({ bloomListingCardPhoto: '' })}
201
218
  style={{ width: '100%', height: '100%' }}
202
219
  />
203
220
  ) : null}
@@ -623,6 +640,9 @@ function ListingCardComponent(props: ListingCardProps) {
623
640
  favorite = false,
624
641
  onFavoriteChange,
625
642
  onPress,
643
+ onLongPress,
644
+ onContextMenu,
645
+ hoverZoom = false,
626
646
  href,
627
647
  loading = false,
628
648
  layout = 'vertical',
@@ -727,6 +747,17 @@ function ListingCardComponent(props: ListingCardProps) {
727
747
  role: href ? ('link' as const) : onPress ? ('button' as const) : undefined,
728
748
  accessibilityLabel: name,
729
749
  onPress: onPress || (!IS_WEB && href) ? handlePress : undefined,
750
+ onLongPress,
751
+ // Same shape as `MessageBubble`'s: the event is defaulted here, so the
752
+ // browser's own menu does not open over the card's shortcut.
753
+ ...(IS_WEB && onContextMenu !== undefined
754
+ ? {
755
+ onContextMenu: (event: { preventDefault?: () => void }) => {
756
+ event.preventDefault?.();
757
+ onContextMenu();
758
+ },
759
+ }
760
+ : null),
730
761
  testID: testID ? `${testID}-link` : undefined,
731
762
  };
732
763
 
@@ -752,7 +783,9 @@ function ListingCardComponent(props: ListingCardProps) {
752
783
 
753
784
  return (
754
785
  <View
755
- {...webDataSet({ bloomListingCard: layout })}
786
+ {...webDataSet(
787
+ hoverZoom ? { bloomListingCard: layout, bloomListingCardZoom: '' } : { bloomListingCard: layout },
788
+ )}
756
789
  style={[{ position: 'relative' }, style]}
757
790
  testID={testID}
758
791
  >
@@ -22,15 +22,22 @@ import type { WishlistCardProps } from './types';
22
22
  * A saved collection of stays.
23
23
  *
24
24
  * cover a square, radius 16, 2px seams in the page colour:
25
+ * 0 photos the placeholder square, holding `empty` centred
25
26
  * 1 photo fills it
26
27
  * 2 side by side
27
28
  * 3 one tall photo left, two stacked right
28
29
  * 4+ a 2×2 (the first four)
29
- * text 12 below: the name body-semibold, the description body-regular
30
- * text-secondary
30
+ * text 12 below: `icon` at 16 (in `color`), the name body-semibold, then
31
+ * the description body-regular text-secondary
31
32
  *
32
33
  * A link on web with `href` (a real `<a>`), otherwise a button with `onPress`;
33
34
  * its name is "name, description".
35
+ *
36
+ * `icon` is DECORATIVE and sits beside the name rather than inside it — a glyph
37
+ * put in the `name` string is read out as its character, and cannot be coloured
38
+ * or sized. `color` paints that glyph and tints the empty cover; it is the
39
+ * caller's colour, not a token, because a collection's colour is the person's
40
+ * choice and Bloom has no ramp for it.
34
41
  */
35
42
 
36
43
  const SEAM = 2;
@@ -57,6 +64,9 @@ function WishlistCardComponent({
57
64
  photoVariant,
58
65
  onPress,
59
66
  href,
67
+ icon: Icon,
68
+ color,
69
+ empty,
60
70
  accessibilityLabel,
61
71
  style,
62
72
  testID,
@@ -67,10 +77,25 @@ function WishlistCardComponent({
67
77
  const resolver = useImageResolver();
68
78
  const uris = photos.slice(0, 4).map((photo) => resolvePhoto(photo, resolver, photoVariant));
69
79
  const count = Math.max(1, uris.length);
80
+ const isEmpty = uris.length === 0;
70
81
 
71
82
  const fill: ViewStyle = { flex: 1 };
72
83
  let cover: React.ReactNode;
73
- if (count === 1) {
84
+ if (isEmpty) {
85
+ cover = (
86
+ <View
87
+ style={{
88
+ flex: 1,
89
+ alignItems: 'center',
90
+ justifyContent: 'center',
91
+ backgroundColor: color ?? paint.photoPlaceholder,
92
+ }}
93
+ testID={testID ? `${testID}-empty` : undefined}
94
+ >
95
+ {empty ?? null}
96
+ </View>
97
+ );
98
+ } else if (count === 1) {
74
99
  cover = <Tile uri={uris[0]} paint={paint} style={fill} />;
75
100
  } else if (count === 2) {
76
101
  cover = (
@@ -133,9 +158,20 @@ function WishlistCardComponent({
133
158
  {cover}
134
159
  </View>
135
160
  <View style={{ marginTop: 12, gap: 2 }}>
136
- <Text variant="body-semibold" numberOfLines={1} style={{ color: paint.text }}>
137
- {name}
138
- </Text>
161
+ <View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
162
+ {Icon ? (
163
+ <View aria-hidden style={{ flexShrink: 0 }} testID={testID ? `${testID}-icon` : undefined}>
164
+ <Icon width={16} height={16} fill={color ?? paint.textSecondary} />
165
+ </View>
166
+ ) : null}
167
+ <Text
168
+ variant="body-semibold"
169
+ numberOfLines={1}
170
+ style={{ flexShrink: 1, minWidth: 0, color: paint.text }}
171
+ >
172
+ {name}
173
+ </Text>
174
+ </View>
139
175
  {description ? (
140
176
  <Text variant="body-regular" numberOfLines={1} style={{ color: paint.textSecondary }}>
141
177
  {description}
@@ -20,6 +20,13 @@ export const MAX_DOTS = 5;
20
20
  export const DOT_SIZE = 6;
21
21
  /** A dot at a window edge that has more photos beyond it. */
22
22
  export const DOT_EDGE_SIZE = 4;
23
+ /**
24
+ * How far `hoverZoom` scales the photo under a pointer. 1.06 — enough to read
25
+ * as the photo coming forward, small enough that the crop barely moves and a
26
+ * face near an edge is not pushed out of frame. The tile clips, so nothing
27
+ * overflows the card.
28
+ */
29
+ export const PHOTO_ZOOM_SCALE = 1.06;
23
30
 
24
31
  export interface ListingCardPaint {
25
32
  /** The placeholder behind a photo that has not loaded, and the mosaic gaps' tiles. */
@@ -209,6 +216,7 @@ export const LISTING_CARD_STYLE_ID = 'bloom-listing-card-web-css';
209
216
  const CARD = '[data-bloom-listing-card]';
210
217
  const LINK = '[data-bloom-listing-card-link]';
211
218
  const TRACK = '[data-bloom-listing-card-track]';
219
+ const PHOTO = '[data-bloom-listing-card-photo]';
212
220
  const ARROW = '[data-bloom-listing-card-arrow]';
213
221
  const DOT = '[data-bloom-listing-card-dot]';
214
222
  const FAVORITE = '[data-bloom-favorite-button]';
@@ -262,9 +270,23 @@ ${FAVORITE}:focus-visible {
262
270
  outline: 2px solid var(--bloom-listing-card-ring, currentColor);
263
271
  outline-offset: 0;
264
272
  }
273
+ ${CARD}[data-bloom-listing-card-zoom] ${PHOTO} {
274
+ transition: transform 400ms cubic-bezier(0.2, 0, 0.2, 1);
275
+ transform-origin: center;
276
+ }
277
+ @media (any-hover: hover) {
278
+ ${CARD}[data-bloom-listing-card-zoom]:hover ${PHOTO} {
279
+ transform: scale(${PHOTO_ZOOM_SCALE});
280
+ }
281
+ }
265
282
  @media (prefers-reduced-motion: reduce) {
266
283
  ${ARROW}, ${DOT} {
267
284
  transition: none;
268
285
  }
286
+ ${CARD}[data-bloom-listing-card-zoom] ${PHOTO},
287
+ ${CARD}[data-bloom-listing-card-zoom]:hover ${PHOTO} {
288
+ transition: none;
289
+ transform: none;
290
+ }
269
291
  }
270
292
  `;
@@ -128,6 +128,26 @@ export interface ListingCardProps {
128
128
  onFavoriteChange?: (favorite: boolean) => void;
129
129
  /** Called when the card is pressed. */
130
130
  onPress?: () => void;
131
+ /**
132
+ * A long press on the card — a shortcut to a sheet or a menu ("save to a
133
+ * folder"). A long press has no keyboard equivalent and no name to announce,
134
+ * so whatever it reaches must be reachable another way too.
135
+ */
136
+ onLongPress?: () => void;
137
+ /**
138
+ * Web only — right-click, the pointer's spelling of the same shortcut. The
139
+ * event is defaulted for you, so the browser's own menu ("open in a new tab")
140
+ * does not appear on the card while this is set. Same contract as
141
+ * `MessageBubble`'s.
142
+ */
143
+ onContextMenu?: () => void;
144
+ /**
145
+ * Zooms the photo a little while the pointer is over the card (WEB ONLY —
146
+ * there is no hover on a touch screen, and no reduced-motion-safe way to do
147
+ * it on native). Default `false`. Skipped under
148
+ * `prefers-reduced-motion: reduce`.
149
+ */
150
+ hoverZoom?: boolean;
131
151
  /**
132
152
  * The stay's URL. On web the card is a real `<a href>` (open in a new tab,
133
153
  * copy link); with `onPress` too the default navigation is prevented so a
@@ -206,6 +226,23 @@ export interface WishlistCardProps {
206
226
  onPress?: () => void;
207
227
  /** The collection's URL; a real `<a href>` on web, like `ListingCard`. */
208
228
  href?: string;
229
+ /**
230
+ * A glyph left of the name, 16px — what the collection IS ("Coast weekends"
231
+ * beside a heart, "Viewings booked" beside a calendar). Decorative: the name
232
+ * beside it is what a screen reader reads.
233
+ */
234
+ icon?: BloomIconComponent;
235
+ /**
236
+ * The `icon`'s colour, and the tint of the cover while the collection is
237
+ * empty. Default: the secondary text colour, and the plain placeholder.
238
+ */
239
+ color?: string;
240
+ /**
241
+ * What the cover shows while `photos` is empty — a glyph, a line of text, an
242
+ * "Add your first save" affordance. Without it an empty collection draws the
243
+ * plain placeholder square it draws today.
244
+ */
245
+ empty?: ReactNode;
209
246
  /** Replaces the composed name ("Coast weekends, 12 saved"). */
210
247
  accessibilityLabel?: string;
211
248
  style?: StyleProp<ViewStyle>;
@@ -0,0 +1,214 @@
1
+ import React, { memo, useCallback, useMemo, useState } from 'react';
2
+ import { Platform, Pressable, View } from 'react-native';
3
+
4
+ import { resolveButtonRamps } from '../button/shared';
5
+ import { RiStarFill } from '../icons/remix/RiStarFill';
6
+ import { RiStarLine } from '../icons/remix/RiStarLine';
7
+ import { focusRingShadow, interactiveWebCss, useInteractiveWebCss } from '../styles/interactive-web-css';
8
+ import { useRingOffsetStyle } from '../styles/surface-levels';
9
+ import { DISABLED_OPACITY } from '../styles/tokens';
10
+ import { webDataSet } from '../styles/web-data';
11
+ import type { WebCssStyle } from '../styles/web-view-style';
12
+ import { useTheme } from '../theme/use-theme';
13
+ import type { RatingInputProps, RatingInputSize } from './types';
14
+
15
+ /**
16
+ * The star PICKER — `Rating`'s counterpart for a review form. `Rating` draws a
17
+ * rating that exists; this one is how a rating comes to exist.
18
+ *
19
+ * star gap
20
+ * small 24 4
21
+ * medium 32 6
22
+ * large 40 8
23
+ *
24
+ * Chosen stars are filled with the ACCENT, the rest are outlines in the
25
+ * neutral ramp — the same reasoning as `RatingBar`'s fill: a chosen star is a
26
+ * choice the person made, and text-primary reads as ink rather than as an
27
+ * answer. Under a pointer the stars up to the hovered one fill as a preview,
28
+ * which never touches `value`.
29
+ *
30
+ * Accessibility: a `radiogroup` named by `accessibilityLabel`, holding one
31
+ * `radio` per star named by `formatStarLabel` ("4 stars"). Exactly one of N is
32
+ * what a rating is, so `radio` is the role rather than a `slider` — it is also
33
+ * the only one that gives each star its own touch target and its own name.
34
+ * Roving tab stop: the chosen star (the first when nothing is chosen) is the
35
+ * one in the tab order, and ArrowRight/Up, ArrowLeft/Down, Home and End move
36
+ * AND choose, which is how an ARIA radio group behaves. The focus ring is drawn
37
+ * on the star itself.
38
+ *
39
+ * **No half stars, and no clearing.** Half a star doubles the keyboard stops
40
+ * and halves the target — 16px of a 32px star, under any touch floor — and the
41
+ * icon set has no half-star glyph, so the state could not be drawn honestly
42
+ * either. Clearing is left out for the reason `Radio` re-choosing is a no-op: a
43
+ * radio has no "off", and a form that needs "no rating yet" holds `null` and
44
+ * does not render a cleared state the group cannot express. `Rating` still
45
+ * DISPLAYS fractional values — a display has no target and no keyboard.
46
+ */
47
+
48
+ const SIZE_CONFIG: Record<RatingInputSize, { star: number; gap: number }> = {
49
+ small: { star: 24, gap: 4 },
50
+ medium: { star: 32, gap: 6 },
51
+ large: { star: 40, gap: 8 },
52
+ };
53
+
54
+ const IS_WEB = Platform.OS === 'web';
55
+
56
+ const HIT_SLOP = { top: 6, bottom: 6, left: 2, right: 2 } as const;
57
+
58
+ const STYLE_ID = 'bloom-rating-input-web-css';
59
+ const STAR = '[data-bloom-rating-star]';
60
+
61
+ const BLOOM_RATING_INPUT_CSS = interactiveWebCss({
62
+ selector: STAR,
63
+ varPrefix: 'bloom-rating-input',
64
+ // A star is a `View`-based press target, not a `<button>`: the button reset's
65
+ // `inline-flex` changes an RNW `View`'s layout.
66
+ reset: 'none',
67
+ base: 'border-radius: 6px;',
68
+ transition: 'box-shadow 120ms ease',
69
+ disabled: { opacity: null },
70
+ extraRules: `${STAR}:focus-visible {
71
+ outline: none;
72
+ box-shadow: ${focusRingShadow('--bloom-rating-input-ring')};
73
+ }`,
74
+ });
75
+
76
+ /** `1 star`, `4 stars`. English — `formatStarLabel` replaces it. */
77
+ export function defaultStarLabel(value: number): string {
78
+ return value === 1 ? '1 star' : `${value} stars`;
79
+ }
80
+
81
+ function RatingInputComponent({
82
+ value,
83
+ onChange,
84
+ max = 5,
85
+ size = 'medium',
86
+ disabled = false,
87
+ accessibilityLabel,
88
+ formatStarLabel = defaultStarLabel,
89
+ style,
90
+ testID,
91
+ }: RatingInputProps) {
92
+ const theme = useTheme();
93
+ const ringOffset = useRingOffsetStyle();
94
+ useInteractiveWebCss(STYLE_ID, BLOOM_RATING_INPUT_CSS);
95
+ const { accent, neutral } = useMemo(() => resolveButtonRamps(theme), [theme]);
96
+ const config = SIZE_CONFIG[size];
97
+ const [hovered, setHovered] = useState<number | null>(null);
98
+
99
+ const total = Math.max(1, Math.round(max));
100
+ const chosen = value == null ? null : Math.min(total, Math.max(1, Math.round(value)));
101
+ // The hover preview never survives a disabled group.
102
+ const shown = disabled ? chosen : (hovered ?? chosen);
103
+
104
+ const choose = useCallback(
105
+ (next: number) => {
106
+ if (disabled) return;
107
+ const clamped = Math.min(total, Math.max(1, next));
108
+ // Re-choosing the chosen star is a no-op, as on `Radio`: firing would
109
+ // report a change that did not happen.
110
+ if (clamped === chosen) return;
111
+ onChange(clamped);
112
+ },
113
+ [disabled, total, chosen, onChange],
114
+ );
115
+
116
+ const onKeyDown = useCallback(
117
+ (e: { key: string; preventDefault: () => void }) => {
118
+ const from = chosen ?? 0;
119
+ switch (e.key) {
120
+ case 'ArrowRight':
121
+ case 'ArrowUp':
122
+ e.preventDefault();
123
+ choose(Math.min(total, from + 1));
124
+ break;
125
+ case 'ArrowLeft':
126
+ case 'ArrowDown':
127
+ e.preventDefault();
128
+ choose(Math.max(1, from - 1));
129
+ break;
130
+ case 'Home':
131
+ e.preventDefault();
132
+ choose(1);
133
+ break;
134
+ case 'End':
135
+ e.preventDefault();
136
+ choose(total);
137
+ break;
138
+ default:
139
+ break;
140
+ }
141
+ },
142
+ [chosen, total, choose],
143
+ );
144
+
145
+ // The roving tab stop: the chosen star, or the first one while nothing is
146
+ // chosen, so a keyboard reaches the group in one Tab either way.
147
+ const tabStop = chosen ?? 1;
148
+ const emptyColor = theme.isDark ? neutral[600] : neutral[300];
149
+
150
+ const starStyle: WebCssStyle = {
151
+ padding: 2,
152
+ borderRadius: 6,
153
+ '--bloom-rating-input-ring': accent[500],
154
+ ...ringOffset,
155
+ };
156
+
157
+ return (
158
+ <View
159
+ testID={testID}
160
+ accessibilityRole="radiogroup"
161
+ accessibilityLabel={accessibilityLabel}
162
+ aria-label={accessibilityLabel}
163
+ aria-disabled={disabled || undefined}
164
+ style={[
165
+ {
166
+ flexDirection: 'row',
167
+ alignItems: 'center',
168
+ gap: config.gap,
169
+ opacity: disabled ? DISABLED_OPACITY : 1,
170
+ },
171
+ style,
172
+ ]}
173
+ >
174
+ {Array.from({ length: total }, (_, index) => {
175
+ const star = index + 1;
176
+ const filled = shown != null && star <= shown;
177
+ const Glyph = filled ? RiStarFill : RiStarLine;
178
+ const name = formatStarLabel(star, total);
179
+ const webProps: Record<string, unknown> = IS_WEB
180
+ ? { tabIndex: disabled || star !== tabStop ? -1 : 0, onKeyDown }
181
+ : {};
182
+ return (
183
+ <Pressable
184
+ key={star}
185
+ accessibilityRole="radio"
186
+ // `aria-checked`, not `accessibilityState`: react-native-web never
187
+ // reads the latter. React Native folds this one back, so it is the
188
+ // spelling both platforms honour.
189
+ aria-checked={star === chosen}
190
+ accessibilityLabel={name}
191
+ disabled={disabled}
192
+ onPress={() => choose(star)}
193
+ onHoverIn={() => setHovered(star)}
194
+ onHoverOut={() => setHovered((current) => (current === star ? null : current))}
195
+ hitSlop={HIT_SLOP}
196
+ {...webProps}
197
+ {...webDataSet({ bloomRatingStar: '' })}
198
+ testID={testID ? `${testID}-star-${star}` : undefined}
199
+ style={starStyle}
200
+ >
201
+ <Glyph
202
+ width={config.star}
203
+ height={config.star}
204
+ fill={filled ? accent[500] : emptyColor}
205
+ />
206
+ </Pressable>
207
+ );
208
+ })}
209
+ </View>
210
+ );
211
+ }
212
+
213
+ export const RatingInput = memo(RatingInputComponent);
214
+ RatingInput.displayName = 'RatingInput';
@@ -1,3 +1,11 @@
1
1
  export { Rating } from './Rating';
2
2
  export { RatingBar } from './RatingBar';
3
- export type { RatingBarProps, RatingCountStyle, RatingProps, RatingSize } from './types';
3
+ export { RatingInput } from './RatingInput';
4
+ export type {
5
+ RatingBarProps,
6
+ RatingCountStyle,
7
+ RatingInputProps,
8
+ RatingInputSize,
9
+ RatingProps,
10
+ RatingSize,
11
+ } from './types';
@@ -32,6 +32,39 @@ export interface RatingProps {
32
32
  testID?: string;
33
33
  }
34
34
 
35
+ /** `small` is a 24px star, `medium` (default) 32, `large` 40. */
36
+ export type RatingInputSize = 'small' | 'medium' | 'large';
37
+
38
+ export interface RatingInputProps {
39
+ /**
40
+ * The chosen rating, `1..max`, or `null` while nothing is chosen. Fully
41
+ * controlled — the component keeps no value of its own.
42
+ */
43
+ value: number | null;
44
+ /** Called with the chosen rating. Never called with the value already chosen. */
45
+ onChange: (value: number) => void;
46
+ /** How many stars. Default `5`. */
47
+ max?: number;
48
+ /** Default `medium`. */
49
+ size?: RatingInputSize;
50
+ /** Disables the whole group: no press, no keyboard, dimmed. */
51
+ disabled?: boolean;
52
+ /**
53
+ * The NAME of what is being rated ("Overall rating", "Cleanliness").
54
+ * Required: the group draws nothing but stars, so nothing else can tell a
55
+ * screen reader which of several ratings this one is.
56
+ */
57
+ accessibilityLabel: string;
58
+ /**
59
+ * Each star's own name, which is English by default (`"1 star"`,
60
+ * `"4 stars"`). Pass a translated formatter.
61
+ */
62
+ formatStarLabel?: (value: number, max: number) => string;
63
+ style?: StyleProp<ViewStyle>;
64
+ /** Derives `-star-<n>` for each star. */
65
+ testID?: string;
66
+ }
67
+
35
68
  export interface RatingBarProps {
36
69
  /** What the row measures ("Cleanliness", "5 stars"). Also the bar's accessible name. */
37
70
  label: string;