@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
@@ -0,0 +1,162 @@
1
+ import React, { memo, useEffect, useMemo, useState } from 'react';
2
+ import { Platform, TextInput, type TextStyle } from 'react-native';
3
+
4
+ import { MENU_FONT_FAMILY } from '../floating/menu-type';
5
+ import { DISABLED_OPACITY } from '../styles/tokens';
6
+ import type { WebCssStyle } from '../styles/web-view-style';
7
+ import { useTheme } from '../theme/use-theme';
8
+ import { TYPE_SCALE } from '../typography';
9
+ import { resolveCalendarPalette } from './palette';
10
+ import { formatTime, parseTime, snapTime, stepTime } from './time';
11
+ import type { TimeFieldProps, TimeFieldSize } from './types';
12
+
13
+ /**
14
+ * A time of day, typed. `DatePicker` is the day; this is the hour and minute,
15
+ * and it is a FIELD rather than a fourth popover — a viewing at 18:30 is four
16
+ * keystrokes, and a popover is the wrong instrument for four keystrokes.
17
+ *
18
+ * width height text step arrows
19
+ * small 96 32 body-2-medium yes
20
+ * medium 104 38 body-medium yes
21
+ *
22
+ * Geometry, palette and focus behaviour are `DateChipInput`'s — the
23
+ * `DD/MM/YYYY` chip inside the pickers — so a date and a time typed beside each
24
+ * other line up: 1px `chipBorder` darkening to `chipBorderFocused` on focus
25
+ * (the border IS the focus indicator, `outline-none`), radius 10, shadow-xs,
26
+ * the panel fill.
27
+ *
28
+ * It keeps its own DRAFT while typing, so the text is not reformatted under the
29
+ * cursor, and commits on blur or submit; Escape reverts. A draft that does not
30
+ * parse reverts too, rather than reporting an error the field has no room to
31
+ * draw — `parseTime` is forgiving enough (`9`, `930`, `9.30`, `9pm`, `21:30`)
32
+ * that the remaining failures really are not times.
33
+ *
34
+ * ArrowUp / ArrowDown move by `step` and commit immediately, clamped at `min`
35
+ * and `max` rather than wrapping. They are a convenience on a text box, not a
36
+ * `spinbutton`: the control is a text field, it announces itself as one, and
37
+ * every value it can reach can also be typed. Its NAME is
38
+ * `accessibilityLabel` — required, because `"--:--"` names nothing.
39
+ *
40
+ * The VALUE is always 24h `"HH:mm"`, whatever `hourFormat` draws, so it drops
41
+ * straight into `MeetingScheduler`'s `timeSlots` and needs no locale from the
42
+ * caller.
43
+ */
44
+
45
+ const SIZE_CONFIG: Record<TimeFieldSize, { width: number; height: number; type: 'body-medium' | 'body-2-medium' }> = {
46
+ small: { width: 96, height: 32, type: 'body-2-medium' },
47
+ medium: { width: 104, height: 38, type: 'body-medium' },
48
+ };
49
+
50
+ /** `transition-colors duration-100 ease-out` on the border. Web only. */
51
+ const FIELD_TRANSITION: WebCssStyle | null =
52
+ Platform.OS === 'web'
53
+ ? { transitionProperty: 'border-color', transitionDuration: '100ms', transitionTimingFunction: 'ease-out' }
54
+ : null;
55
+
56
+ function TimeFieldComponent({
57
+ value,
58
+ onChange,
59
+ min,
60
+ max,
61
+ step = 1,
62
+ hourFormat = '24h',
63
+ size = 'medium',
64
+ disabled = false,
65
+ accessibilityLabel,
66
+ placeholder,
67
+ width,
68
+ style,
69
+ testID,
70
+ }: TimeFieldProps) {
71
+ const theme = useTheme();
72
+ const palette = useMemo(() => resolveCalendarPalette(theme), [theme]);
73
+ const config = SIZE_CONFIG[size];
74
+
75
+ const shown = value ? formatTime(value, hourFormat) : '';
76
+ const [text, setText] = useState(shown);
77
+ const [focused, setFocused] = useState(false);
78
+
79
+ // The committed value is the source of truth; a change from outside (or a
80
+ // format flip) rewrites the draft.
81
+ useEffect(() => {
82
+ setText(shown);
83
+ }, [shown]);
84
+
85
+ const commit = (draft: string) => {
86
+ if (draft.trim() === '') {
87
+ setText('');
88
+ if (value !== null) onChange(null);
89
+ return;
90
+ }
91
+ const parsed = parseTime(draft, hourFormat);
92
+ const next = parsed ? snapTime(parsed, { step, min, max }) : null;
93
+ if (!next) {
94
+ setText(shown);
95
+ return;
96
+ }
97
+ setText(formatTime(next, hourFormat));
98
+ if (next !== value) onChange(next);
99
+ };
100
+
101
+ const nudge = (direction: 1 | -1) => {
102
+ const next = stepTime(value, direction, { step, min, max });
103
+ if (!next) return;
104
+ setText(formatTime(next, hourFormat));
105
+ if (next !== value) onChange(next);
106
+ };
107
+
108
+ const fieldStyle: TextStyle = {
109
+ width: width ?? config.width,
110
+ height: config.height,
111
+ paddingLeft: 8,
112
+ paddingRight: 8,
113
+ borderWidth: 1,
114
+ borderRadius: 10,
115
+ borderColor: focused ? palette.chipBorderFocused : palette.chipBorder,
116
+ backgroundColor: palette.panel,
117
+ boxShadow: palette.shadowXs,
118
+ color: disabled ? palette.disabledText : palette.text,
119
+ opacity: disabled ? DISABLED_OPACITY : 1,
120
+ // A `TextInput` inherits no face.
121
+ ...TYPE_SCALE[config.type],
122
+ ...MENU_FONT_FAMILY,
123
+ // `outline-none`: the border change is the focus indicator.
124
+ outlineWidth: 0,
125
+ };
126
+
127
+ return (
128
+ <TextInput
129
+ value={text}
130
+ editable={!disabled}
131
+ onChangeText={setText}
132
+ onFocus={() => setFocused(true)}
133
+ onBlur={() => {
134
+ setFocused(false);
135
+ commit(text);
136
+ }}
137
+ onSubmitEditing={() => commit(text)}
138
+ onKeyPress={(event) => {
139
+ const key = event.nativeEvent.key;
140
+ if (key === 'Escape') {
141
+ setText(shown);
142
+ } else if (key === 'ArrowUp') {
143
+ (event as unknown as { preventDefault?: () => void }).preventDefault?.();
144
+ nudge(1);
145
+ } else if (key === 'ArrowDown') {
146
+ (event as unknown as { preventDefault?: () => void }).preventDefault?.();
147
+ nudge(-1);
148
+ }
149
+ }}
150
+ placeholder={placeholder ?? (hourFormat === '12h' ? '--:-- --' : '--:--')}
151
+ placeholderTextColor={palette.secondaryText}
152
+ inputMode={hourFormat === '12h' ? 'text' : 'numeric'}
153
+ accessibilityLabel={accessibilityLabel}
154
+ aria-disabled={disabled || undefined}
155
+ style={[FIELD_TRANSITION, fieldStyle, style]}
156
+ testID={testID}
157
+ />
158
+ );
159
+ }
160
+
161
+ export const TimeField = memo(TimeFieldComponent);
162
+ TimeField.displayName = 'TimeField';
@@ -2,6 +2,9 @@ export { Calendar, RangeCalendar } from './Calendar';
2
2
  export { DatePicker } from './DatePicker';
3
3
  export { DateRangePicker } from './DateRangePicker';
4
4
  export { MeetingScheduler } from './MeetingScheduler';
5
+ export { TimeField } from './TimeField';
6
+ // `time.ts`'s pure helpers stay internal, as `calendar-grid.ts`'s already do:
7
+ // `formatTime`/`parseTime` are names the root barrel cannot afford.
5
8
 
6
9
  export type {
7
10
  CalendarConstraintProps,
@@ -9,6 +12,7 @@ export type {
9
12
  DatePickerProps,
10
13
  DateRange,
11
14
  DateRangePickerProps,
15
+ HourFormat,
12
16
  MeetingSchedulerDetails,
13
17
  MeetingSchedulerHost,
14
18
  MeetingSchedulerHourFormat,
@@ -16,5 +20,7 @@ export type {
16
20
  MeetingSchedulerProps,
17
21
  MeetingSchedulerValue,
18
22
  RangeCalendarProps,
23
+ TimeFieldProps,
24
+ TimeFieldSize,
19
25
  WeekStart,
20
26
  } from './types';
@@ -0,0 +1,158 @@
1
+ import type { HourFormat } from './types';
2
+
3
+ /**
4
+ * Time of day as the family already spells it: a 24-hour `"HH:mm"` string.
5
+ * `MeetingScheduler`'s `timeSlots` and `MeetingSchedulerValue.time` are the same
6
+ * shape, so a `TimeField` value drops straight into one.
7
+ *
8
+ * Everything here is pure, exported, and where the parsing rules live — a field
9
+ * that keeps its own draft cannot be tested through the DOM for every spelling
10
+ * somebody might type.
11
+ */
12
+
13
+ /** Minutes since midnight, `0..1439`. */
14
+ export type Minutes = number;
15
+
16
+ const HH_MM = /^([01]?\d|2[0-3]):([0-5]\d)$/;
17
+
18
+ /** `"14:30"` → 870. `null` for anything that is not a 24h `HH:mm`. */
19
+ export function toMinutes(value: string): Minutes | null {
20
+ const match = HH_MM.exec(value.trim());
21
+ if (!match) return null;
22
+ return Number(match[1]) * 60 + Number(match[2]);
23
+ }
24
+
25
+ /** 870 → `"14:30"`. Wraps within the day, so 1440 is `"00:00"`. */
26
+ export function fromMinutes(minutes: Minutes): string {
27
+ const wrapped = ((Math.round(minutes) % 1440) + 1440) % 1440;
28
+ const h = Math.floor(wrapped / 60);
29
+ const m = wrapped % 60;
30
+ return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
31
+ }
32
+
33
+ /** `"14:30"` → `"14:30"` (24h) or `"2:30 PM"` (12h). */
34
+ export function formatTime(value: string, hourFormat: HourFormat): string {
35
+ const minutes = toMinutes(value);
36
+ if (minutes == null) return value;
37
+ if (hourFormat === '24h') return fromMinutes(minutes);
38
+ const h = Math.floor(minutes / 60);
39
+ const m = minutes % 60;
40
+ const period = h < 12 ? 'AM' : 'PM';
41
+ const hour12 = h % 12 === 0 ? 12 : h % 12;
42
+ return `${hour12}:${String(m).padStart(2, '0')} ${period}`;
43
+ }
44
+
45
+ /**
46
+ * What somebody typed → `"HH:mm"`, or `null` when it is not a time.
47
+ *
48
+ * Deliberately forgiving, because a field that only accepts what it printed is
49
+ * a field people fight: `9`, `930`, `9.30`, `9:3`, `0930`, `9 30`, `9pm`,
50
+ * `9:30 p.m.` and `21:30` all parse. A bare number under 24 is an HOUR (`9` is
51
+ * 09:00), four digits are `HHMM` (`0930`); `AM`/`PM` decides the half of the
52
+ * day whatever `hourFormat` says, because somebody who typed `pm` means pm.
53
+ * Without a marker, 24h reads the hour as given and 12h keeps `1..12` in the
54
+ * morning — the value is still `"HH:mm"`, so a caller never has to know which.
55
+ */
56
+ export function parseTime(text: string, hourFormat: HourFormat = '24h'): string | null {
57
+ const raw = text.trim().toLowerCase();
58
+ if (raw === '') return null;
59
+ const period = /(^|[^a-z])(a\.?m\.?|p\.?m\.?)\s*$/.exec(raw);
60
+ const meridiem = period ? (period[2]!.startsWith('a') ? 'am' : 'pm') : null;
61
+ const digits = (meridiem ? raw.slice(0, period!.index + period![1]!.length) : raw).trim();
62
+
63
+ let hour: number;
64
+ let minute: number;
65
+ const separated = /^(\d{1,2})\s*[:.\s]\s*(\d{1,2})$/.exec(digits);
66
+ if (separated) {
67
+ hour = Number(separated[1]);
68
+ minute = Number(separated[2]!.padEnd(2, '0'));
69
+ } else if (/^\d{3,4}$/.test(digits)) {
70
+ hour = Number(digits.slice(0, digits.length - 2));
71
+ minute = Number(digits.slice(-2));
72
+ } else if (/^\d{1,2}$/.test(digits)) {
73
+ hour = Number(digits);
74
+ minute = 0;
75
+ } else {
76
+ return null;
77
+ }
78
+
79
+ if (minute > 59) return null;
80
+ if (meridiem) {
81
+ if (hour < 1 || hour > 12) return null;
82
+ hour = meridiem === 'pm' ? (hour % 12) + 12 : hour % 12;
83
+ } else if (hour > 23) {
84
+ return null;
85
+ }
86
+ return fromMinutes(hour * 60 + minute);
87
+ }
88
+
89
+ /**
90
+ * The nearest multiple of `step` minutes (or the next one up/down, per
91
+ * `round`), at or after `min` and at or before `max`. `step` counts from
92
+ * MIDNIGHT, so 15 gives :00 :15 :30 :45 whatever the bounds are — a step
93
+ * measured from `min` would put the round times off the grid the moment a shop
94
+ * opened at 09:10. Never crosses the end of the day: 23:59 rounded up to a
95
+ * 30-minute grid stays inside it rather than wrapping to the other end.
96
+ */
97
+ export function snapTime(
98
+ value: string,
99
+ {
100
+ step = 1,
101
+ min,
102
+ max,
103
+ round = 'nearest',
104
+ }: { step?: number; min?: string; max?: string; round?: 'nearest' | 'up' | 'down' } = {},
105
+ ): string | null {
106
+ const minutes = toMinutes(value);
107
+ if (minutes == null) return null;
108
+ const size = Math.max(1, Math.round(step));
109
+ const scaled = minutes / size;
110
+ let snapped =
111
+ (round === 'up' ? Math.ceil(scaled) : round === 'down' ? Math.floor(scaled) : Math.round(scaled)) *
112
+ size;
113
+ const low = min ? toMinutes(min) : null;
114
+ const high = max ? toMinutes(max) : null;
115
+ if (low != null && high != null && low > high) return null;
116
+ if (low != null && snapped < low) snapped = low;
117
+ if (high != null && snapped > high) snapped = high;
118
+ // Never past the end of the day: rounding 23:59 up to a 30-minute grid lands
119
+ // on 24:00, which `fromMinutes` would wrap to midnight — the other end.
120
+ if (snapped > 1439) snapped = high ?? 1439;
121
+ if (snapped < 0) snapped = low ?? 0;
122
+ return fromMinutes(snapped);
123
+ }
124
+
125
+ /**
126
+ * One arrow press: `direction * step` minutes from `value`, or the first time
127
+ * inside the bounds when there is no value yet. Clamps at the bounds rather
128
+ * than wrapping — a wrap past `max` back to `min` reads as the field refusing
129
+ * the key.
130
+ */
131
+ export function stepTime(
132
+ value: string | null,
133
+ direction: 1 | -1,
134
+ { step = 1, min, max }: { step?: number; min?: string; max?: string } = {},
135
+ ): string | null {
136
+ const size = Math.max(1, Math.round(step));
137
+ const low = min ? toMinutes(min) : null;
138
+ const high = max ? toMinutes(max) : null;
139
+ if (low != null && high != null && low > high) return null;
140
+ const current = value ? toMinutes(value) : null;
141
+ if (current == null) {
142
+ // Into the range, not past it: up from the earliest allowed time rounds UP
143
+ // onto the grid, down from the latest rounds DOWN.
144
+ const start = direction === 1 ? (low ?? 0) : (high ?? 1439);
145
+ return snapTime(fromMinutes(start), {
146
+ step: size,
147
+ min,
148
+ max,
149
+ round: direction === 1 ? 'up' : 'down',
150
+ });
151
+ }
152
+ let next = current + direction * size;
153
+ if (low != null && next < low) next = low;
154
+ if (high != null && next > high) next = high;
155
+ if (next < 0) next = 0;
156
+ if (next > 1439) next = 1439;
157
+ return fromMinutes(next);
158
+ }
@@ -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
  `;