@oxy.so/bloom 2.11.0 → 2.12.1

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 (187) 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/message-bubble/MessageBubble.js +16 -1
  32. package/lib/commonjs/message-bubble/MessageBubble.js.map +1 -1
  33. package/lib/commonjs/rating/RatingInput.js +218 -0
  34. package/lib/commonjs/rating/RatingInput.js.map +1 -0
  35. package/lib/commonjs/rating/index.js +7 -0
  36. package/lib/commonjs/rating/index.js.map +1 -1
  37. package/lib/commonjs/stay-search/DestinationSuggestions.js +46 -9
  38. package/lib/commonjs/stay-search/DestinationSuggestions.js.map +1 -1
  39. package/lib/commonjs/stay-search/GuestPicker.js +9 -0
  40. package/lib/commonjs/stay-search/GuestPicker.js.map +1 -1
  41. package/lib/commonjs/track-list/parts.js +0 -13
  42. package/lib/commonjs/track-list/parts.js.map +1 -1
  43. package/lib/module/app-shell/AppShell.js +121 -5
  44. package/lib/module/app-shell/AppShell.js.map +1 -1
  45. package/lib/module/app-shell/layout.js +10 -5
  46. package/lib/module/app-shell/layout.js.map +1 -1
  47. package/lib/module/content-panel/ContentPanel.js +8 -2
  48. package/lib/module/content-panel/ContentPanel.js.map +1 -1
  49. package/lib/module/content-panel/ContentPanel.web.js +38 -25
  50. package/lib/module/content-panel/ContentPanel.web.js.map +1 -1
  51. package/lib/module/date-picker/TimeField.js +170 -0
  52. package/lib/module/date-picker/TimeField.js.map +1 -0
  53. package/lib/module/date-picker/index.js +3 -0
  54. package/lib/module/date-picker/index.js.map +1 -1
  55. package/lib/module/date-picker/time.js +151 -0
  56. package/lib/module/date-picker/time.js.map +1 -0
  57. package/lib/module/index.js +1 -1
  58. package/lib/module/index.js.map +1 -1
  59. package/lib/module/index.web.js +1 -1
  60. package/lib/module/index.web.js.map +1 -1
  61. package/lib/module/listing-card/ListingCard.js +34 -1
  62. package/lib/module/listing-card/ListingCard.js.map +1 -1
  63. package/lib/module/listing-card/WishlistCard.js +50 -8
  64. package/lib/module/listing-card/WishlistCard.js.map +1 -1
  65. package/lib/module/listing-card/shared.js +22 -0
  66. package/lib/module/listing-card/shared.js.map +1 -1
  67. package/lib/module/message-bubble/MessageBubble.js +16 -1
  68. package/lib/module/message-bubble/MessageBubble.js.map +1 -1
  69. package/lib/module/rating/RatingInput.js +212 -0
  70. package/lib/module/rating/RatingInput.js.map +1 -0
  71. package/lib/module/rating/index.js +1 -0
  72. package/lib/module/rating/index.js.map +1 -1
  73. package/lib/module/stay-search/DestinationSuggestions.js +45 -9
  74. package/lib/module/stay-search/DestinationSuggestions.js.map +1 -1
  75. package/lib/module/stay-search/GuestPicker.js +9 -0
  76. package/lib/module/stay-search/GuestPicker.js.map +1 -1
  77. package/lib/module/track-list/parts.js +1 -14
  78. package/lib/module/track-list/parts.js.map +1 -1
  79. package/lib/typescript/commonjs/app-shell/AppShell.d.ts.map +1 -1
  80. package/lib/typescript/commonjs/app-shell/layout.d.ts +5 -4
  81. package/lib/typescript/commonjs/app-shell/layout.d.ts.map +1 -1
  82. package/lib/typescript/commonjs/app-shell/types.d.ts +12 -2
  83. package/lib/typescript/commonjs/app-shell/types.d.ts.map +1 -1
  84. package/lib/typescript/commonjs/content-panel/ContentPanel.d.ts.map +1 -1
  85. package/lib/typescript/commonjs/content-panel/ContentPanel.web.d.ts.map +1 -1
  86. package/lib/typescript/commonjs/content-panel/types.d.ts +53 -42
  87. package/lib/typescript/commonjs/content-panel/types.d.ts.map +1 -1
  88. package/lib/typescript/commonjs/date-picker/TimeField.d.ts +6 -0
  89. package/lib/typescript/commonjs/date-picker/TimeField.d.ts.map +1 -0
  90. package/lib/typescript/commonjs/date-picker/index.d.ts +2 -1
  91. package/lib/typescript/commonjs/date-picker/index.d.ts.map +1 -1
  92. package/lib/typescript/commonjs/date-picker/time.d.ts +56 -0
  93. package/lib/typescript/commonjs/date-picker/time.d.ts.map +1 -0
  94. package/lib/typescript/commonjs/date-picker/types.d.ts +49 -3
  95. package/lib/typescript/commonjs/date-picker/types.d.ts.map +1 -1
  96. package/lib/typescript/commonjs/index.d.ts +2 -2
  97. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  98. package/lib/typescript/commonjs/index.web.d.ts +2 -2
  99. package/lib/typescript/commonjs/index.web.d.ts.map +1 -1
  100. package/lib/typescript/commonjs/listing-card/ListingCard.d.ts.map +1 -1
  101. package/lib/typescript/commonjs/listing-card/WishlistCard.d.ts +1 -1
  102. package/lib/typescript/commonjs/listing-card/WishlistCard.d.ts.map +1 -1
  103. package/lib/typescript/commonjs/listing-card/shared.d.ts +8 -1
  104. package/lib/typescript/commonjs/listing-card/shared.d.ts.map +1 -1
  105. package/lib/typescript/commonjs/listing-card/types.d.ts +37 -0
  106. package/lib/typescript/commonjs/listing-card/types.d.ts.map +1 -1
  107. package/lib/typescript/commonjs/message-bubble/MessageBubble.d.ts.map +1 -1
  108. package/lib/typescript/commonjs/rating/RatingInput.d.ts +8 -0
  109. package/lib/typescript/commonjs/rating/RatingInput.d.ts.map +1 -0
  110. package/lib/typescript/commonjs/rating/index.d.ts +2 -1
  111. package/lib/typescript/commonjs/rating/index.d.ts.map +1 -1
  112. package/lib/typescript/commonjs/rating/types.d.ts +31 -0
  113. package/lib/typescript/commonjs/rating/types.d.ts.map +1 -1
  114. package/lib/typescript/commonjs/stay-search/DestinationSuggestions.d.ts +8 -0
  115. package/lib/typescript/commonjs/stay-search/DestinationSuggestions.d.ts.map +1 -1
  116. package/lib/typescript/commonjs/stay-search/GuestPicker.d.ts +6 -1
  117. package/lib/typescript/commonjs/stay-search/GuestPicker.d.ts.map +1 -1
  118. package/lib/typescript/commonjs/stay-search/types.d.ts +20 -0
  119. package/lib/typescript/commonjs/stay-search/types.d.ts.map +1 -1
  120. package/lib/typescript/commonjs/track-list/parts.d.ts.map +1 -1
  121. package/lib/typescript/module/app-shell/AppShell.d.ts.map +1 -1
  122. package/lib/typescript/module/app-shell/layout.d.ts +5 -4
  123. package/lib/typescript/module/app-shell/layout.d.ts.map +1 -1
  124. package/lib/typescript/module/app-shell/types.d.ts +12 -2
  125. package/lib/typescript/module/app-shell/types.d.ts.map +1 -1
  126. package/lib/typescript/module/content-panel/ContentPanel.d.ts.map +1 -1
  127. package/lib/typescript/module/content-panel/ContentPanel.web.d.ts.map +1 -1
  128. package/lib/typescript/module/content-panel/types.d.ts +53 -42
  129. package/lib/typescript/module/content-panel/types.d.ts.map +1 -1
  130. package/lib/typescript/module/date-picker/TimeField.d.ts +6 -0
  131. package/lib/typescript/module/date-picker/TimeField.d.ts.map +1 -0
  132. package/lib/typescript/module/date-picker/index.d.ts +2 -1
  133. package/lib/typescript/module/date-picker/index.d.ts.map +1 -1
  134. package/lib/typescript/module/date-picker/time.d.ts +56 -0
  135. package/lib/typescript/module/date-picker/time.d.ts.map +1 -0
  136. package/lib/typescript/module/date-picker/types.d.ts +49 -3
  137. package/lib/typescript/module/date-picker/types.d.ts.map +1 -1
  138. package/lib/typescript/module/index.d.ts +2 -2
  139. package/lib/typescript/module/index.d.ts.map +1 -1
  140. package/lib/typescript/module/index.web.d.ts +2 -2
  141. package/lib/typescript/module/index.web.d.ts.map +1 -1
  142. package/lib/typescript/module/listing-card/ListingCard.d.ts.map +1 -1
  143. package/lib/typescript/module/listing-card/WishlistCard.d.ts +1 -1
  144. package/lib/typescript/module/listing-card/WishlistCard.d.ts.map +1 -1
  145. package/lib/typescript/module/listing-card/shared.d.ts +8 -1
  146. package/lib/typescript/module/listing-card/shared.d.ts.map +1 -1
  147. package/lib/typescript/module/listing-card/types.d.ts +37 -0
  148. package/lib/typescript/module/listing-card/types.d.ts.map +1 -1
  149. package/lib/typescript/module/message-bubble/MessageBubble.d.ts.map +1 -1
  150. package/lib/typescript/module/rating/RatingInput.d.ts +8 -0
  151. package/lib/typescript/module/rating/RatingInput.d.ts.map +1 -0
  152. package/lib/typescript/module/rating/index.d.ts +2 -1
  153. package/lib/typescript/module/rating/index.d.ts.map +1 -1
  154. package/lib/typescript/module/rating/types.d.ts +31 -0
  155. package/lib/typescript/module/rating/types.d.ts.map +1 -1
  156. package/lib/typescript/module/stay-search/DestinationSuggestions.d.ts +8 -0
  157. package/lib/typescript/module/stay-search/DestinationSuggestions.d.ts.map +1 -1
  158. package/lib/typescript/module/stay-search/GuestPicker.d.ts +6 -1
  159. package/lib/typescript/module/stay-search/GuestPicker.d.ts.map +1 -1
  160. package/lib/typescript/module/stay-search/types.d.ts +20 -0
  161. package/lib/typescript/module/stay-search/types.d.ts.map +1 -1
  162. package/lib/typescript/module/track-list/parts.d.ts.map +1 -1
  163. package/package.json +1 -1
  164. package/src/app-shell/AppShell.tsx +91 -4
  165. package/src/app-shell/layout.ts +10 -5
  166. package/src/app-shell/types.ts +12 -2
  167. package/src/content-panel/ContentPanel.tsx +8 -2
  168. package/src/content-panel/ContentPanel.web.tsx +39 -25
  169. package/src/content-panel/types.ts +50 -42
  170. package/src/date-picker/TimeField.tsx +162 -0
  171. package/src/date-picker/index.ts +6 -0
  172. package/src/date-picker/time.ts +158 -0
  173. package/src/date-picker/types.ts +52 -3
  174. package/src/index.ts +9 -2
  175. package/src/index.web.ts +9 -2
  176. package/src/listing-card/ListingCard.tsx +34 -1
  177. package/src/listing-card/WishlistCard.tsx +42 -6
  178. package/src/listing-card/shared.ts +22 -0
  179. package/src/listing-card/types.ts +37 -0
  180. package/src/message-bubble/MessageBubble.tsx +21 -1
  181. package/src/rating/RatingInput.tsx +214 -0
  182. package/src/rating/index.ts +9 -1
  183. package/src/rating/types.ts +33 -0
  184. package/src/stay-search/DestinationSuggestions.tsx +48 -9
  185. package/src/stay-search/GuestPicker.tsx +9 -0
  186. package/src/stay-search/types.ts +20 -0
  187. package/src/track-list/parts.tsx +1 -20
@@ -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>;
@@ -182,6 +182,25 @@ function MessageBubbleComponent({
182
182
  onContextMenu !== undefined ||
183
183
  selected !== undefined;
184
184
 
185
+ /**
186
+ * Whether the bubble CONTAINS controls of its own.
187
+ *
188
+ * A pressable bubble is a button, and on the web a button may not contain
189
+ * another one: a picture, a file row, a voice note's play control, a reaction
190
+ * chip, a retry action and a reply quote are all buttons, so a bubble holding
191
+ * any of them and announcing itself as one renders invalid DOM — React logs
192
+ * "<button> cannot contain a nested <button>" once per bubble and the inner
193
+ * control's own semantics are lost. Such a bubble keeps its press handlers
194
+ * and its accessible name, and gives up the ROLE: it is a container that
195
+ * happens to be pressable, which is what it actually is.
196
+ */
197
+ const holdsControls =
198
+ media !== undefined ||
199
+ (reactions !== undefined && reactions.length > 0) ||
200
+ onRetry !== undefined ||
201
+ onPressReply !== undefined ||
202
+ onAddReaction !== undefined;
203
+
185
204
  const name =
186
205
  accessibilityLabel ??
187
206
  bubbleAccessibleName({
@@ -324,7 +343,8 @@ function MessageBubbleComponent({
324
343
 
325
344
  const bubble = interactive ? (
326
345
  <Pressable
327
- role="button"
346
+ role={holdsControls ? undefined : 'button'}
347
+ accessible={holdsControls ? undefined : true}
328
348
  accessibilityLabel={name}
329
349
  aria-pressed={selected === undefined ? undefined : selected}
330
350
  accessibilityState={selected === undefined ? undefined : { selected }}
@@ -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;
@@ -4,6 +4,7 @@ import { Platform, Pressable, View } from 'react-native';
4
4
  import { resolveButtonRamps } from '../button/shared';
5
5
  import { RiMapPinLine } from '../icons/remix/RiMapPinLine';
6
6
  import { adoptStyleSheet } from '../styles/adopt-style-sheet';
7
+ import { DISABLED_OPACITY } from '../styles/tokens';
7
8
  import type { WebCssStyle } from '../styles/web-view-style';
8
9
  import { webDataSet } from '../styles/web-data';
9
10
  import { useTheme } from '../theme/use-theme';
@@ -20,6 +21,13 @@ import type { DestinationSuggestionsProps } from './types';
20
21
  *
21
22
  * A `listbox` of `option`s. The list takes focus (web) and owns the arrow
22
23
  * keys; the rows stay out of the tab order.
24
+ *
25
+ * A row can be `disabled` — "Use my location" while location permission is
26
+ * off. It stays in the list rather than disappearing, because a row that
27
+ * vanishes teaches nothing: it dims to `DISABLED_OPACITY`, carries
28
+ * `aria-disabled`, answers no press or Enter, is never highlighted by hover, and
29
+ * the arrow keys step OVER it. `disabledReason` replaces its description and is
30
+ * read after the title, so the row says why.
23
31
  */
24
32
 
25
33
  const IS_WEB = Platform.OS === 'web';
@@ -36,6 +44,28 @@ const CSS = `
36
44
  }
37
45
  `;
38
46
 
47
+ /**
48
+ * The next row the arrow keys land on, stepping OVER every disabled one and
49
+ * wrapping once. `-1` when every row is disabled — the highlight then stays
50
+ * where it is rather than landing on a row that answers nothing. Pure.
51
+ */
52
+ export function nextSelectable(
53
+ items: readonly { disabled?: boolean }[],
54
+ from: number,
55
+ step: 1 | -1,
56
+ ): number {
57
+ const count = items.length;
58
+ if (count === 0) return -1;
59
+ // From "nothing highlighted", ArrowDown starts at the top and ArrowUp at the
60
+ // bottom, which is what the list did before any row could be disabled.
61
+ const start = from < 0 ? (step === 1 ? 0 : count - 1) : (from + step + count) % count;
62
+ for (let i = 0; i < count; i++) {
63
+ const index = (start + step * i + count * count) % count;
64
+ if (!items[index]?.disabled) return index;
65
+ }
66
+ return -1;
67
+ }
68
+
39
69
  function DestinationSuggestionsComponent({
40
70
  items,
41
71
  onSelect,
@@ -68,13 +98,16 @@ function DestinationSuggestionsComponent({
68
98
  if (items.length === 0) return;
69
99
  if (e.key === 'ArrowDown') {
70
100
  e.preventDefault();
71
- highlight(current < 0 ? 0 : (current + 1) % items.length);
101
+ const next = nextSelectable(items, current, 1);
102
+ if (next >= 0) highlight(next);
72
103
  } else if (e.key === 'ArrowUp') {
73
104
  e.preventDefault();
74
- highlight(current <= 0 ? items.length - 1 : current - 1);
105
+ const next = nextSelectable(items, current, -1);
106
+ if (next >= 0) highlight(next);
75
107
  } else if (e.key === 'Enter' && current >= 0 && current < items.length) {
76
108
  e.preventDefault();
77
- onSelect(items[current]!);
109
+ const item = items[current]!;
110
+ if (!item.disabled) onSelect(item);
78
111
  }
79
112
  };
80
113
 
@@ -103,17 +136,22 @@ function DestinationSuggestionsComponent({
103
136
  >
104
137
  {items.map((item, index) => {
105
138
  const Icon = item.icon ?? RiMapPinLine;
106
- const selected = index === current;
139
+ const disabled = item.disabled === true;
140
+ const selected = index === current && !disabled;
141
+ const secondLine = disabled ? (item.disabledReason ?? item.description) : item.description;
107
142
  return (
108
143
  <Pressable
109
144
  key={item.id}
110
145
  role="option"
111
- accessibilityLabel={item.description ? `${item.title}, ${item.description}` : item.title}
146
+ accessibilityLabel={secondLine ? `${item.title}, ${secondLine}` : item.title}
112
147
  aria-selected={selected}
113
- accessibilityState={{ selected }}
148
+ accessibilityState={{ selected, disabled }}
149
+ disabled={disabled}
114
150
  focusable={false}
115
151
  onPress={() => onSelect(item)}
116
- onHoverIn={() => highlight(index)}
152
+ onHoverIn={() => {
153
+ if (!disabled) highlight(index);
154
+ }}
117
155
  testID={testID ? `${testID}-${index}` : undefined}
118
156
  style={{
119
157
  flexDirection: 'row',
@@ -124,6 +162,7 @@ function DestinationSuggestionsComponent({
124
162
  paddingLeft: 12,
125
163
  paddingRight: 12,
126
164
  borderRadius: 16,
165
+ opacity: disabled ? DISABLED_OPACITY : 1,
127
166
  backgroundColor: selected ? palette.rowHighlight : 'transparent',
128
167
  }}
129
168
  >
@@ -143,9 +182,9 @@ function DestinationSuggestionsComponent({
143
182
  <Text variant="body-medium" numberOfLines={1} style={{ color: palette.text }}>
144
183
  {item.title}
145
184
  </Text>
146
- {item.description ? (
185
+ {secondLine ? (
147
186
  <Text variant="body-2-regular" numberOfLines={1} style={{ color: palette.textSecondary }}>
148
- {item.description}
187
+ {secondLine}
149
188
  </Text>
150
189
  ) : null}
151
190
  </View>
@@ -19,6 +19,11 @@ const COUNTED: readonly GuestKind[] = ['adults', 'children'];
19
19
  * them — the one guests picker, used by the search bar's "Who" panel and the
20
20
  * booking card's guests popover alike. Then an optional note and "Close".
21
21
  *
22
+ * The stepper button names travel too: `decrementLabel` / `incrementLabel`
23
+ * reach every row, so the two words a screen reader reads on the eight buttons
24
+ * are translatable like everything else the family draws. Their default is
25
+ * `Stepper`'s own English pair.
26
+ *
22
27
  * Two rules:
23
28
  * - while any child, infant or pet is counted, adults cannot go below 1 (the
24
29
  * adults `−` disables there), and adding one of them with no adults sets
@@ -35,6 +40,8 @@ function GuestPickerComponent({
35
40
  kinds = GUEST_KINDS,
36
41
  labels,
37
42
  descriptions,
43
+ decrementLabel,
44
+ incrementLabel,
38
45
  note,
39
46
  onClose,
40
47
  closeLabel = 'Close',
@@ -71,6 +78,8 @@ function GuestPickerComponent({
71
78
  min={kind === 'adults' ? minimumAdults(value) : 0}
72
79
  max={maxFor(kind)}
73
80
  size={size}
81
+ decrementLabel={decrementLabel}
82
+ incrementLabel={incrementLabel}
74
83
  onValueChange={(n) => onChange(applyGuestCount(value, kind, n))}
75
84
  divider={index < kinds.length - 1}
76
85
  testID={testID ? `${testID}-${kind}` : undefined}
@@ -97,6 +97,18 @@ export interface DestinationSuggestion {
97
97
  description?: string;
98
98
  /** A Remix icon component (`RiMapPinLine`, `RiTimeLine`…). Default `RiMapPinLine`. */
99
99
  icon?: ButtonIconComponent;
100
+ /**
101
+ * The row is offered but cannot be chosen — "Use my location" while location
102
+ * is off. It dims, answers no press, and the arrow keys step over it.
103
+ */
104
+ disabled?: boolean;
105
+ /**
106
+ * Why it cannot be chosen ("Location is off"). Drawn in place of
107
+ * `description` while `disabled`, and read after the title, so the row says
108
+ * what is wrong rather than only looking wrong. Ignored when the row is not
109
+ * disabled.
110
+ */
111
+ disabledReason?: string;
100
112
  }
101
113
 
102
114
  export interface DestinationSuggestionsProps {
@@ -135,6 +147,14 @@ export interface GuestPickerProps {
135
147
  labels?: Partial<Record<GuestKind, string>>;
136
148
  /** Override descriptions (`null` hides one). */
137
149
  descriptions?: Partial<Record<GuestKind, string | null>>;
150
+ /**
151
+ * The name of every row's `−` button, which is English by default
152
+ * (`"Decrease"`). Same spelling as `Stepper`'s, and passed to every row — the
153
+ * row's own title is what says WHICH counter it belongs to.
154
+ */
155
+ decrementLabel?: string;
156
+ /** The name of every row's `+` button. Default `"Increase"`. */
157
+ incrementLabel?: string;
138
158
  /**
139
159
  * The most guests the place takes, counting adults and children (infants and
140
160
  * pets do not count). Their `+` buttons disable once the sum reaches it.
@@ -1,4 +1,4 @@
1
- import React, { Fragment, memo, useMemo, useState } from 'react';
1
+ import React, { Fragment, memo, useMemo } from 'react';
2
2
  import { Image, View, type GestureResponderEvent } from 'react-native';
3
3
 
4
4
  import { GlyphButton } from '../button';
@@ -13,8 +13,6 @@ import { RiMore2Fill } from '../icons/remix/RiMore2Fill';
13
13
  import { RiMusic2Line } from '../icons/remix/RiMusic2Line';
14
14
  import { resolvePhoto } from '../listing-card/shared';
15
15
  import { useImageResolver } from '../image-resolver/context';
16
- import { DISABLED_OPACITY, borderRadius } from '../styles/tokens';
17
- import type { WebCssStyle } from '../styles/web-view-style';
18
16
  import type { WebAriaProps } from '../styles/styled-primitives';
19
17
  import { useTheme } from '../theme/use-theme';
20
18
  import { IS_WEB, resolveTrackListPaint } from './shared';
@@ -58,23 +56,6 @@ export function TrackIconButton({
58
56
  }: TrackIconButtonProps) {
59
57
  const theme = useTheme();
60
58
  const paint = useMemo(() => resolveTrackListPaint(theme), [theme]);
61
- const [hovered, setHovered] = useState(false);
62
- const color =
63
- pressed && activeColor
64
- ? activeColor
65
- : hovered && !disabled
66
- ? paint.text
67
- : paint.textMuted;
68
- const rootStyle: WebCssStyle = {
69
- width: 32,
70
- height: 32,
71
- borderRadius: borderRadius.full,
72
- alignItems: 'center',
73
- justifyContent: 'center',
74
- opacity: disabled ? DISABLED_OPACITY : 1,
75
- '--bloom-track-ring': paint.ring,
76
- };
77
- const toggle = pressed !== undefined;
78
59
  return (
79
60
  <GlyphButton
80
61
  {...aria}