@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
@@ -32,8 +32,14 @@ export type AppShellDrawer = 'overlay' | 'reveal';
32
32
  * - `focus` — a single centred column of `contentWidth` and NO nav: sign-in,
33
33
  * onboarding, a reader. It keeps the header, `topBar`, `bottomBar` and
34
34
  * `floatingAction` slots, so a sign-up flow still gets its footer CTA.
35
+ * - `canvas` — the content is the SCREEN: one edge-to-edge area with no reading
36
+ * column, no max width and no padding, exactly one viewport tall, and the
37
+ * chrome floats over it. A map, a board, an editor, a photo viewer. Each
38
+ * region keeps its own inset (the nav is still a card with a gutter around
39
+ * it) so only the canvas runs to the edge. Like `split`, it never scrolls the
40
+ * document — the canvas owns its own panning and zooming.
35
41
  */
36
- export type AppShellVariant = 'dashboard' | 'feed' | 'split' | 'focus';
42
+ export type AppShellVariant = 'dashboard' | 'feed' | 'split' | 'focus' | 'canvas';
37
43
 
38
44
  /** Which single pane a `split` shell shows below `splitFrom`. */
39
45
  export type AppShellPane = 'list' | 'detail' | 'info';
@@ -108,7 +114,11 @@ export interface AppShellProps {
108
114
  asideWidth?: number;
109
115
  /** The breakpoint the aside sits beside the content from. Defaults to `xl` (1280). */
110
116
  asideFrom?: AppShellBreakpoint;
111
- /** Below `asideFrom`: `stack` (default) renders it after the content, `hidden` drops it. */
117
+ /**
118
+ * Below `asideFrom`: `stack` (default) renders it after the content, `hidden`
119
+ * drops it. `canvas` always drops it — the screen is the canvas, so there is
120
+ * no column underneath to stack into.
121
+ */
112
122
  asideCollapse?: 'stack' | 'hidden';
113
123
  children?: ReactNode;
114
124
  /** The content column's max width. Defaults to 1300. */
@@ -71,6 +71,7 @@ const ContentPanelComponent: React.FC<ContentPanelProps> = ({
71
71
  children,
72
72
  framed,
73
73
  framedFrom = 768,
74
+ fill = false,
74
75
  surfaceClassName,
75
76
  surfaceStyle,
76
77
  surfaceColor,
@@ -97,7 +98,7 @@ const ContentPanelComponent: React.FC<ContentPanelProps> = ({
97
98
  : framed
98
99
  ? 'flex-1 overflow-hidden rounded-radius-28 border border-border'
99
100
  : 'flex-1';
100
- const surfaceClass = [surfaceBase, surfaceClassName ?? 'bg-card'].join(' ');
101
+ const surfaceClass = [surfaceBase, fill ? 'min-h-0' : '', surfaceClassName ?? 'bg-card'].filter(Boolean).join(' ');
101
102
  // Native has no overlays: the surface itself carries the edge. `none` drops
102
103
  // both, `border` keeps the class-drawn hairline, `elevated` adds the lift.
103
104
  const chromeStyle =
@@ -106,7 +107,12 @@ const ContentPanelComponent: React.FC<ContentPanelProps> = ({
106
107
  : shadow || panelChrome
107
108
  ? { boxShadow: shadow ?? panelChrome?.shadow }
108
109
  : null;
109
- const contentClass = ['flex-1', contentClassName].filter(Boolean).join(' ');
110
+ // `fill` is the panel's own height contract, and on native it is already
111
+ // met: both boxes are `flex: 1`, so in a bounded parent the panel reaches the
112
+ // bottom and a list inside it scrolls within it. What the prop adds here is
113
+ // `min-h-0`, so a tall child cannot push the panel past the box it was given
114
+ // — the same automatic-minimum-size trap as on web.
115
+ const contentClass = ['flex-1', fill ? 'min-h-0' : '', contentClassName].filter(Boolean).join(' ');
110
116
 
111
117
  return (
112
118
  <ContentPanelNestingContext.Provider value={true}>
@@ -148,6 +148,7 @@ const ContentPanelComponent: React.FC<ContentPanelProps> = ({
148
148
  children,
149
149
  framed,
150
150
  framedFrom = 768,
151
+ fill = false,
151
152
  surfaceClassName,
152
153
  surfaceStyle,
153
154
  surfaceColor,
@@ -157,8 +158,8 @@ const ContentPanelComponent: React.FC<ContentPanelProps> = ({
157
158
  chrome = 'elevated',
158
159
  shadow,
159
160
  maskColor,
160
- overlaySizing = 'panel',
161
- overlayTopOffset,
161
+ overlaySizing = 'viewport',
162
+ overlayInset,
162
163
  }) => {
163
164
  // Dev-only invariant — must run unconditionally (before deriving any
164
165
  // mode-specific branch) so the hook order stays stable (rules of hooks).
@@ -194,6 +195,15 @@ const ContentPanelComponent: React.FC<ContentPanelProps> = ({
194
195
  : framed
195
196
  ? 'flex-1 rounded-radius-28 web:overflow-x-clip'
196
197
  : 'flex-1';
198
+ // `fill`: the panel takes the height its parent gives it and the CONTENT
199
+ // scrolls inside. `min-h-0` on both boxes is the whole trick — a flex child's
200
+ // automatic minimum size is its content, so without it the panel grows past
201
+ // the box it was told to fit and the scroller never gets a height to scroll
202
+ // against. The overflow goes on the content wrapper, never the surface: the
203
+ // surface is what the frame and the mask are drawn to, and a scrolling
204
+ // surface would scroll the edge with it.
205
+ const fillSurface = fill ? 'min-h-0' : '';
206
+ const fillContent = fill ? 'min-h-0 web:[overflow-y:auto]' : '';
197
207
  // `overlaySizing="panel"`: the surface becomes a single-cell CSS Grid so the
198
208
  // overlays and the content wrapper below can all be placed in that ONE cell
199
209
  // (`web:[grid-area:1/1]`) and each fill it exactly — `minmax(0,1fr)` (not
@@ -204,32 +214,36 @@ const ContentPanelComponent: React.FC<ContentPanelProps> = ({
204
214
  const gridStackClass = boundToPanel
205
215
  ? 'web:grid web:[grid-template-columns:minmax(0,1fr)] web:[grid-template-rows:minmax(0,1fr)]'
206
216
  : '';
207
- const surfaceClass = [surfaceBase, gridStackClass, surfaceClassName ?? 'bg-card'].filter(Boolean).join(' ');
208
- const contentClass = [contentBase, boundToPanel ? 'web:[grid-area:1/1]' : '', contentClassName]
217
+ const surfaceClass = [surfaceBase, fillSurface, gridStackClass, surfaceClassName ?? 'bg-card'].filter(Boolean).join(' ');
218
+ const contentClass = [contentBase, fillContent, boundToPanel ? 'web:[grid-area:1/1]' : '', contentClassName]
209
219
  .filter(Boolean)
210
220
  .join(' ');
211
221
 
212
- // `overlayTopOffset`: the viewport-mode overlays are sized/positioned from
213
- // literal Tailwind classes (`top-2`, `h-[calc(100dvh-16px)]`, a matching
214
- // negative `margin-bottom`) because those values are constants known at
215
- // build time. An offset is a runtime number (a consumer's measured header
216
- // height), which a Tailwind arbitrary class can't express — content-scanning
217
- // needs the literal class string in source, not a value computed later — so
218
- // this shifts the same three properties via inline `style` instead, which
219
- // has no such constraint. No-op in `panel` mode (already starts at the
220
- // panel's own box) and when unset (0/undefined) — the className values are
221
- // left standing on their own in both of those cases.
222
+ // The viewport-mode overlays are sized and positioned from literal Tailwind
223
+ // classes (`top-2`, `h-[calc(100dvh-16px)]`, the matching negative
224
+ // `margin-bottom`) because those values are constants known at build time.
225
+ // An INSET is a runtime number — a shell's gutter, a measured header — which
226
+ // a Tailwind arbitrary class cannot express (content-scanning needs the
227
+ // literal string in source, not a value computed later), so a non-default
228
+ // inset shifts the same three properties through inline `style` instead,
229
+ // which has no such constraint. The default (8/8) leaves the classes standing
230
+ // alone, and `panel` mode ignores the inset entirely — that mode already
231
+ // starts at the panel's own box.
232
+ //
222
233
  // RN's `ViewStyle.height`/`marginBottom` types only accept a number or a
223
- // `${number}%` string (not an arbitrary CSS `calc()` string), because most
224
- // of this type is shared with native, where `calc()` doesn't exist. This
225
- // file is web-only, where it's a real, valid CSS value react-native-web
226
- // passes straight through — the same reasoning `WebViewStyle`/`asViewStyle`
227
- // document in OxyHQ/Mention's `types/webStyles.ts` for the identical need.
228
- const topOffsetStyle = !boundToPanel && overlayTopOffset
234
+ // `${number}%` string, not an arbitrary CSS `calc()`, because most of that
235
+ // type is shared with native where `calc()` does not exist. This file is
236
+ // web-only, where it is a real value react-native-web passes straight
237
+ // through.
238
+ const insetTop = typeof overlayInset === 'number' ? overlayInset : (overlayInset?.top ?? PANEL_TOP_INSET);
239
+ const insetBottom =
240
+ typeof overlayInset === 'number' ? overlayInset : (overlayInset?.bottom ?? PANEL_BOTTOM_INSET);
241
+ const customInset = insetTop !== PANEL_TOP_INSET || insetBottom !== PANEL_BOTTOM_INSET;
242
+ const insetStyle = !boundToPanel && customInset
229
243
  ? ({
230
- top: 8 + overlayTopOffset,
231
- height: `calc(100dvh - ${16 + overlayTopOffset}px)`,
232
- marginBottom: `calc(-100dvh + ${16 + overlayTopOffset}px)`,
244
+ top: insetTop,
245
+ height: `calc(100dvh - ${insetTop + insetBottom}px)`,
246
+ marginBottom: `calc(-100dvh + ${insetTop + insetBottom}px)`,
233
247
  } as unknown as ViewStyle)
234
248
  : undefined;
235
249
 
@@ -261,7 +275,7 @@ const ContentPanelComponent: React.FC<ContentPanelProps> = ({
261
275
  ? `web:sticky web:top-2 z-30 h-[calc(100dvh-16px)] w-full rounded-radius-28 ${bp.overlayHidden} web:[margin-bottom:calc(-100dvh+16px)] web:[clip-path:inset(-12px)]`
262
276
  : 'web:sticky web:top-2 z-30 h-[calc(100dvh-16px)] w-full rounded-radius-28 web:[margin-bottom:calc(-100dvh+16px)] web:[clip-path:inset(-12px)]'
263
277
  }
264
- style={{ ...topOffsetStyle, boxShadow: `0 0 0 ${GUTTER_MASK_SPREAD}px ${maskColor ?? colors.background}` }}
278
+ style={{ ...insetStyle, boxShadow: `0 0 0 ${GUTTER_MASK_SPREAD}px ${maskColor ?? colors.background}` }}
265
279
  />
266
280
  )}
267
281
  {/* (2) Border-frame overlay — one continuous rounded border, above all.
@@ -274,7 +288,7 @@ const ContentPanelComponent: React.FC<ContentPanelProps> = ({
274
288
  // The shadow rides the SAME element as the hairline, so the lift and
275
289
  // the edge can never disagree about where the panel ends. It paints
276
290
  // outward, into the gutter the page background shows.
277
- style={[topOffsetStyle, chrome === 'elevated' && (shadow || panelChrome)
291
+ style={[insetStyle, chrome === 'elevated' && (shadow || panelChrome)
278
292
  ? { boxShadow: shadow ?? panelChrome?.shadow }
279
293
  : null]}
280
294
  className={
@@ -52,6 +52,28 @@ export interface ContentPanelProps {
52
52
  * For an app that wants its own elevation without giving up the hairline.
53
53
  */
54
54
  shadow?: string;
55
+ /**
56
+ * The panel is the HEIGHT OF THE SPACE IT IS GIVEN, and its content scrolls
57
+ * inside it — instead of the panel growing with its content while the page
58
+ * scrolls underneath.
59
+ *
60
+ * This is what makes a framed column read as a panel: it reaches the bottom
61
+ * of the screen and closes there, the way the rail beside it does. Without
62
+ * it, a feed taller than the window gives a panel whose top edge you see and
63
+ * whose bottom edge is somewhere past the fold — an open-ended column, not a
64
+ * surface.
65
+ *
66
+ * It needs a parent that BOUNDS it (a flex column with a real height): an
67
+ * `AppShell` with `scroll="fixed"` or `scroll="container"`, or any screen
68
+ * that is `flex: 1`. In a document-scrolled page there is no height to fill
69
+ * and the panel keeps growing with its content — that page wants
70
+ * `scroll="document"`, not this prop.
71
+ *
72
+ * On web the content wrapper becomes the scroller. On native the panel and
73
+ * its content are already `flex: 1`, so a `FlatList`/`ScrollView` inside
74
+ * fills the panel and scrolls within it.
75
+ */
76
+ fill?: boolean;
55
77
  /** Override the surface background utility (defaults to `bg-card`). */
56
78
  surfaceClassName?: string;
57
79
  surfaceStyle?: StyleProp<ViewStyle>;
@@ -89,52 +111,38 @@ export interface ContentPanelProps {
89
111
  */
90
112
  maskColor?: string;
91
113
  /**
92
- * How the WEB overlays (bleed-mask, border-frame) size themselves. No-op on
93
- * native (there are no overlays to size).
94
- * - `'viewport'` (DEFAULT) — sized to the dynamic viewport height (`100dvh`)
95
- * and `position: sticky`-pinned, so they read as a constant frame around
96
- * the visible slice of a panel whose own DOM height can exceed one screen
97
- * — the document-scroll case (e.g. a feed whose page grows with content).
98
- * Requires the panel to sit in a document that actually scrolls; see
99
- * `ContentPanel.web.tsx`'s doc comment.
100
- * - `'panel'` — sized to the panel's own real box via CSS Grid layer-
101
- * stacking (the overlays and the content wrapper all occupy the same
102
- * grid cell) instead of viewport math. The right mode for a consumer
103
- * whose own shell already bounds the panel's height (no document
104
- * scroll — a fixed app shell with its own internal scroll containers),
105
- * where anything the consumer places outside the panel (e.g. a header
106
- * above it) must never be overlapped by an overlay sized to the whole
107
- * viewport.
108
- */
109
- /**
110
- * DEFAULT `'panel'`. The edge describes the panel's OWN box, so it scrolls
111
- * with the content like any other border: on a page taller than the screen
112
- * you see the two side edges while scrolling and the rounded corners at the
113
- * ends, which is what a long framed column looks like.
114
+ * How the WEB overlays (the bleed-mask and the border frame) size themselves.
115
+ * No-op on native (there are no overlays to size).
114
116
  *
115
- * `'viewport'` is the older behaviour: the frame is a screen-tall sticky
116
- * rectangle, so a rounded top and bottom edge follow the viewport down the
117
- * page. It reads as the panel breathing — the frame holds still while the
118
- * content moves inside it, and it jumps 8px on the first scroll — which is
119
- * why it is no longer the default. It remains for a consumer that wants that
120
- * constant frame.
117
+ * - `'viewport'` (DEFAULT) — the frame is a SCREEN-TALL sticky rectangle:
118
+ * `100dvh` minus the insets, pinned at `overlayInset`. The panel's own box
119
+ * grows with its content and the document scrolls it, while the edge stays
120
+ * where the screen is — so the panel reads as a surface that occupies the
121
+ * window from top to bottom and the content moves inside it. This is the
122
+ * mode for a page that scrolls the document.
123
+ * - `'panel'` — the overlays are the panel's OWN box, through CSS Grid
124
+ * layer-stacking (the overlays and the content wrapper share one grid
125
+ * cell). The right mode when the panel's box already IS the visible area
126
+ * (`fill`, inside a shell that bounds it) or when something the consumer
127
+ * placed outside the panel — a header above it — must never be painted
128
+ * over by an overlay sized to the whole viewport.
121
129
  */
122
130
  overlaySizing?: 'viewport' | 'panel';
123
131
  /**
124
- * Pixels of REAL, persistent chrome the consumer has placed above the panel
125
- * — outside it, in normal document flow — that the viewport-mode overlays
126
- * (`overlaySizing="viewport"`, the default) do not otherwise know about.
127
- * No-op when `overlaySizing="panel"` (that mode already starts at the
128
- * panel's own box, wherever that is) and on native (no overlays there).
132
+ * The gap between the VIEWPORT's edge and the sticky frame, in `'viewport'`
133
+ * mode. Default 8.
134
+ *
135
+ * **It must equal the distance from the top of the screen to the panel's own
136
+ * top edge.** Sticky positioning leaves an element at its static position
137
+ * until scrolling would push it past the inset — so a panel that starts 16px
138
+ * down the page with an 8px inset draws its frame at 16, then SNAPS it to 8
139
+ * on the first scroll. That 8px jump is the whole bug, and it reads as the
140
+ * panel breathing. A shell passes its own gutter here.
129
141
  *
130
- * The overlays are sized/positioned off the VIEWPORT (`top: 8px`,
131
- * `height: calc(100dvh - 16px)`) on the assumption that the panel begins
132
- * near the true top of the screen. A consumer with its own sticky chrome
133
- * above the panel (a header, say, kept sticky-pinned the same way) shifts
134
- * where the panel visually starts without moving the panel's own DOM
135
- * position, so the overlays would still read from viewport 0 and paint
136
- * over that chrome. This shifts their `top` down and shrinks their height
137
- * by the same amount, so they begin exactly where the panel visually does.
142
+ * Takes a pair when the top and the bottom differ — persistent chrome above
143
+ * the panel (a sticky header the consumer keeps outside it) shifts where the
144
+ * panel visually starts without moving its DOM position, so the frame has to
145
+ * start lower without also ending higher.
138
146
  */
139
- overlayTopOffset?: number;
147
+ overlayInset?: number | { top: number; bottom: number };
140
148
  }
@@ -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
+ }