@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
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  title: AppShell
3
- description: Page frame with four layout variants, the sidebar and a responsive drawer, pinned top/bottom/floating slots, a breadcrumb + title header, a notification bell and a floating Pro offer card.
3
+ description: Page frame with five layout variants, the sidebar and a responsive drawer, pinned top/bottom/floating slots, a breadcrumb + title header, a notification bell and a floating Pro offer card.
4
4
  order: 83
5
5
  ---
6
6
 
@@ -27,9 +27,9 @@ import { AppShell, NotificationBell, ProOfferCard } from '@oxy.so/bloom/app-shel
27
27
 
28
28
  ## Variants
29
29
 
30
- One engine, four shapes. `variant` picks the shape; everything else — the nav,
30
+ One engine, five shapes. `variant` picks the shape; everything else — the nav,
31
31
  the drawer, the pinned regions, the slots, the sizes — works the same in all
32
- four.
32
+ five.
33
33
 
34
34
  | `variant` | The page | Scroll |
35
35
  | --- | --- | --- |
@@ -37,6 +37,7 @@ four.
37
37
  | `feed` | nav + a `contentWidth` reading column CENTRED in what the nav leaves, optional `aside` beside it, optionally in a `ContentPanel` | `document` / `container` / `fixed` |
38
38
  | `split` | a list pane, a detail pane and an optional info pane, each scrolling on its own, with a draggable divider | always one screen |
39
39
  | `focus` | one centred `contentWidth` column and NO nav | `document` / `container` / `fixed` |
40
+ | `canvas` | ONE edge-to-edge area — no column, no cap, no padding — with the chrome floating over it | always one screen |
40
41
 
41
42
  `dashboard` is what this component always was, unchanged to the pixel — the
42
43
  existing stories render byte-identically against the version before variants
@@ -62,12 +63,59 @@ aside at `y = 16` while the content moved from `16` to `-390`.
62
63
 
63
64
  `panel` wraps the centre column in a [`ContentPanel`](/docs/bloom/main/content-panel),
64
65
  so the page background reads as a gutter around a framed surface; `framedFrom`
65
- is forwarded. The panel's content is inset by `gutter` — without it the page sits
66
+ is forwarded.
67
+
68
+ **The panel occupies the screen either way — what differs is what scrolls.**
69
+
70
+ - `scroll="document"` (the default): the page scrolls, and the panel's frame is
71
+ a screen-tall sticky rectangle pinned at the shell's own `gutter`, so the
72
+ panel reads as a surface filling the window while the content moves inside
73
+ it. Measured in Chrome at 1280 × 900: the frame reports `top: 16, height: 868`
74
+ at scrollY 0, 1, 200, 600 and 846 — the same rectangle every time — while the
75
+ content behind it travels from 16 to −830. (The inset is what makes that
76
+ true: at the 8px default the frame would sit above the panel's real top edge
77
+ and snap down on the first scroll.)
78
+ - `scroll="fixed"` / `"container"`: the shell bounds the panel, so the panel IS
79
+ the box — it closes at the bottom like the rail beside it, the header is
80
+ pinned inside it, and only the routed content scrolls, under the frame. The
81
+ document never moves at all.
82
+
83
+ Both are right; they are different apps. A feed with the address bar collapsing
84
+ on a phone and real anchor links wants `document`; a workspace whose regions
85
+ each keep their own position wants `fixed`. The panel's content is inset by `gutter` — without it the page sits
66
86
  ON the frame, and a radius-28 corner clips whatever is in it (the heading loses
67
87
  its first letter). So `contentWidth` is the COLUMN's width, and the reading
68
88
  measure inside a panel is that minus two gutters. Do not render your own
69
89
  `ContentPanel` inside this one; it trips the nesting guard.
70
90
 
91
+ ### `canvas`
92
+
93
+ ```tsx
94
+ <AppShell
95
+ variant="canvas"
96
+ sidebar={{ ...sidebar, surface: 'docked' }}
97
+ aside={<PlacesInView />}
98
+ floatingAction={<Button variant="primary" leadingIcon={RiAddFill}>Add a pin</Button>}
99
+ >
100
+ <Map />
101
+ </AppShell>
102
+ ```
103
+
104
+ The content **is** the screen: one area, edge to edge, no reading column, no max
105
+ width, no padding, exactly one viewport tall. A map, a board, an editor, a photo
106
+ viewer. The header (if you pass one) sits above it in flow, because a toolbar
107
+ over a map belongs to the map; `topBar`, `bottomBar`, `floatingAction` and
108
+ `overlay` float over it as everywhere else.
109
+
110
+ The shell's padding moves OUT of the row and onto the regions, so the nav is
111
+ still a card with a gutter around it and the aside keeps its inset while the
112
+ canvas alone runs to the window's edge — no negative margins anywhere: each
113
+ region states its own inset.
114
+
115
+ **`canvas` never scrolls the document**, for the same reason `split` doesn't: a
116
+ canvas that grew the page would pan the page instead of the canvas.
117
+ `scroll="document"` resolves to `fixed`, `scroll="container"` is honoured.
118
+
71
119
  ### `split`
72
120
 
73
121
  ```tsx
@@ -267,6 +315,44 @@ overlapping another, and no console error.
267
315
  A second column on the right of the page (`dashboard` and `feed`; `split` has
268
316
  its `info` pane instead and `focus` has no second column at all). From `asideFrom` (`xl`, 1280; or `md` / `lg`) it sits beside the content at `asideWidth` (320), pinned like the sidebar — with document scroll it is sticky and scrolls its own overflow, so give it `flex: 1` content if it should fill the height. Below the breakpoint it is stacked after the content (`asideCollapse="stack"`, the default) or dropped (`"hidden"`). Under `drawer="reveal"` it slides with the page.
269
317
 
318
+ ## Eight shells, one component
319
+
320
+ The shape (`variant`), what scrolls (`scroll`), whether the column is a panel
321
+ (`panel`) and the nav's own look (`sidebar.surface` / `sidebar.size`) are
322
+ independent. That is the point: an app picks a row from this table and does not
323
+ look like the app that picked the row above it.
324
+
325
+ | The app | variant | scroll | nav |
326
+ | --- | --- | --- | --- |
327
+ | Product dashboard | `dashboard` | `document` | `card`, `medium` |
328
+ | Social reader | `feed` + `panel` | `fixed` | `plain`, `large` |
329
+ | Long-form feed | `feed` | `document` | `card`, `medium` |
330
+ | Workspace (channels, files) | `dashboard` | `fixed` | `docked`, `small` |
331
+ | Messenger | `split` | one screen | `docked`, `small` |
332
+ | Map / board / editor | `canvas` | one screen | `docked`, `medium` |
333
+ | Marketplace | `dashboard`, no `sidebar`, `topBar` `always` | `document` | — |
334
+ | Sign-in, onboarding, reader | `focus` | `document` | none |
335
+
336
+ ```tsx
337
+ // Social reader: a framed column that fills the screen, a nav with no edge.
338
+ <AppShell variant="feed" panel scroll="fixed" sidebar={{ items, surface: 'plain', size: 'large' }} title="Home">
339
+ {feed}
340
+ </AppShell>
341
+
342
+ // Workspace: a column flush to the window, the page bounded, its own scrollers.
343
+ <AppShell scroll="fixed" sidebar={{ items, surface: 'docked', size: 'small' }} title="Reports">
344
+ {report}
345
+ </AppShell>
346
+
347
+ // Marketplace: no rail at all — the navigation is the bar across the top.
348
+ <AppShell topBar={<MarketHeader />} topBarVisibility="always" contentMaxWidth={1280}>
349
+ {results}
350
+ </AppShell>
351
+ ```
352
+
353
+ The stories **Three identities**, **Feed in a full-height panel** and **Canvas**
354
+ render three of these side by side with nothing positioned by hand.
355
+
270
356
  ## The nav's identity is not the shell's layout
271
357
 
272
358
  `variant` picks the LAYOUT; `sidebar.surface` and `sidebar.size` pick what the navigation looks like. They are separate axes on purpose — the same shell carries a social reader, a workspace and a product dashboard without any of them re-implementing it:
@@ -51,28 +51,48 @@ the shadow is what makes it a panel lifted off it.
51
51
  | `surfaceColor` | what the panel tells its content it is painted in — only when `surfaceClassName`/`surfaceStyle` repainted it |
52
52
  | `contentStyle` / `contentClassName` | the column inside it |
53
53
  | `maskColor` | the colour the gutter mask fades to |
54
- | `overlaySizing` | (web) how the mask/border overlays size themselves — see below |
54
+ | `overlaySizing` | (web) the screen's frame (default) or the panel's own box — see below |
55
+ | `overlayInset` | (web) where that sticky frame pins — match your layout's gutter |
56
+ | `fill` | the panel is the height it is given; its content scrolls inside |
55
57
 
56
- ## `overlaySizing`: the panel's own box, or a constant frame
58
+ ## `overlaySizing`: the screen's frame, or the panel's own box
57
59
 
58
60
  The web overlays (the border frame and the gutter bleed-mask) default to
59
- **`overlaySizing="panel"`**: sized to the panel's own real box through CSS Grid
60
- layer-stacking. The edge behaves like any other border — it scrolls with the
61
- content, you see the two side edges while reading and the rounded corners at the
62
- ends of the page.
63
-
64
- `overlaySizing="viewport"` is the other mode: the overlays are `100dvh` and
65
- `position: sticky`-pinned, so a rounded top and bottom edge follow the viewport
66
- down the page. It was the default until it was measured against a long feed: the
67
- frame holds still while the content moves inside it and it jumps 8px on the
68
- first scroll, which reads as the panel breathing. It remains for a consumer that
69
- wants that constant frame — and it is also the mode `overlayTopOffset` exists
70
- for.
71
-
72
- Panel mode is additionally the right answer whenever the consumer's OWN shell
73
- bounds the panel (a fixed app shell with internal scroll containers): a
74
- viewport-sized overlay there paints over anything placed outside the panel, such
75
- as a header above it.
61
+ **`overlaySizing="viewport"`**: a screen-tall sticky rectangle, `100dvh` minus
62
+ the insets. The panel's own box grows with its content and the document scrolls
63
+ it, while the edge stays where the screen is — so the panel occupies the window
64
+ from top to bottom and the content moves inside it. That is what makes a
65
+ document-scrolled column read as a surface rather than as an open-ended strip
66
+ whose bottom edge is somewhere past the fold.
67
+
68
+ **`overlayInset` has to match where the panel really starts.** Sticky
69
+ positioning leaves an element at its static position until scrolling would push
70
+ it past the inset, so a panel that begins 16px down the page with the default
71
+ 8px inset draws its frame at 16 and then SNAPS it to 8 on the first scroll. That
72
+ 8px jump is what "the panel moves when I scroll" is. Pass the gutter your
73
+ layout uses:
74
+
75
+ ```tsx
76
+ <View style={{ padding: 16 }}>
77
+ <ContentPanel framed overlayInset={16}>{page}</ContentPanel>
78
+ </View>
79
+ ```
80
+
81
+ `AppShell` passes its own `gutter` for you. Measured in Chrome at 1280 × 900,
82
+ `variant="feed" panel`: the frame reports `top: 16, height: 868` at scrollY 0,
83
+ 1, 200, 600 and 846 — the same rectangle at every one — while the surface
84
+ behind it moves from 16 to −830.
85
+
86
+ An inset PAIR (`{ top, bottom }`) is for persistent chrome the consumer keeps
87
+ outside the panel: a sticky header above it shifts where the panel visually
88
+ starts without moving its DOM position, so the frame starts lower without also
89
+ ending higher.
90
+
91
+ `overlaySizing="panel"` is the other mode: the overlays are the panel's own box,
92
+ through CSS Grid layer-stacking. Use it when the panel's box already IS the
93
+ visible area (`fill`, below) or when something placed outside the panel must
94
+ never be painted over by an overlay sized to the whole viewport — a bounded app
95
+ shell with its own internal scrollers.
76
96
 
77
97
  ```tsx
78
98
  <View style={{ flex: 1 }}>
@@ -83,6 +103,32 @@ as a header above it.
83
103
  </View>
84
104
  ```
85
105
 
106
+ ## `fill`: a panel that reaches the bottom
107
+
108
+ ```tsx
109
+ <AppShell scroll="fixed"> {/* something has to bound it */}
110
+ <ContentPanel framed fill>
111
+ <FlatList … /> {/* scrolls INSIDE the panel */}
112
+ </ContentPanel>
113
+ </AppShell>
114
+ ```
115
+
116
+ By default the panel grows with its content and the page scrolls: on a feed
117
+ taller than the window you see the panel's top edge and its bottom edge is
118
+ somewhere past the fold — an open-ended column, not a surface.
119
+
120
+ `fill` makes the panel the height of the space it is given, with its content
121
+ scrolling inside it. It closes at the bottom of the screen, the way the
122
+ [`Sidebar`](/docs/bloom/main/sidebar) beside it does, and the frame never moves
123
+ because the frame IS that box.
124
+
125
+ It needs a parent that bounds it — an `AppShell` with `scroll="fixed"` or
126
+ `scroll="container"` (which passes `fill` for you when you ask for `panel`), or
127
+ any screen that is `flex: 1`. In a document-scrolled page there is no height to
128
+ fill and the panel keeps growing; that page wants `scroll="document"`, not this
129
+ prop. On web the content wrapper becomes the scroller; on native the panel is
130
+ already `flex: 1`, so a `FlatList` inside fills it and scrolls within it.
131
+
86
132
  ## `framedFrom` is why the branch is not in your screen
87
133
 
88
134
  A phone wants a plain column: a frame a few pixels from the screen edge reads
@@ -6,16 +6,19 @@ order: 13
6
6
 
7
7
  # DatePicker
8
8
 
9
- `DatePicker`, `DateRangePicker` and `MeetingScheduler`. Bloom takes no dependency on react-aria or `@internationalized/date`, so the month layout, selection, constraints and keyboard navigation are plain TypeScript over local-midnight `Date`s. Colours come from the Bloom-derived ramps in `src/button/shared.ts`.
9
+ `DatePicker`, `DateRangePicker`, `MeetingScheduler` and `TimeField`. Bloom takes no dependency on react-aria or `@internationalized/date`, so the month layout, selection, constraints and keyboard navigation are plain TypeScript over local-midnight `Date`s. Colours come from the Bloom-derived ramps in `src/button/shared.ts`.
10
10
 
11
11
  ```tsx
12
- import { DatePicker, DateRangePicker, MeetingScheduler, Calendar, RangeCalendar } from '@oxy.so/bloom/date-picker';
12
+ import { DatePicker, DateRangePicker, MeetingScheduler, TimeField, Calendar, RangeCalendar } from '@oxy.so/bloom/date-picker';
13
13
 
14
14
  const [day, setDay] = useState<Date | null>(null);
15
15
  <DatePicker value={day} onChange={setDay} minDate={new Date()} />
16
16
 
17
17
  const [range, setRange] = useState<DateRange | null>(null);
18
18
  <DateRangePicker value={range} onChange={setRange} />
19
+
20
+ const [time, setTime] = useState<string | null>('18:30');
21
+ <TimeField value={time} onChange={setTime} step={15} accessibilityLabel="Viewing time" />
19
22
  ```
20
23
 
21
24
  ## Pickers
@@ -81,3 +84,78 @@ A range takes two presses in either order; between them the band follows the poi
81
84
  | `disabled?` | `boolean` | `false` | Blocks opening (pickers). |
82
85
  | `RangeCalendar.visibleMonths?` | `1 \| 2` | `1` | Months side by side. |
83
86
  | `defaultMonth?` | `Date` | value's month, else today's | First month shown (calendars). |
87
+
88
+ ## TimeField
89
+
90
+ `DatePicker` is the day; `TimeField` is the hour and minute. It is a **field,
91
+ not a fourth popover** — a viewing at 18:30 is four keystrokes, and a popover is
92
+ the wrong instrument for four keystrokes. (`MeetingScheduler` still has the
93
+ other shape: a fixed list of bookable slots, which is a different question —
94
+ "which of these?" rather than "when?".)
95
+
96
+ ```tsx
97
+ const [time, setTime] = useState<string | null>(null);
98
+ <TimeField
99
+ value={time}
100
+ onChange={setTime}
101
+ min="09:00"
102
+ max="20:00"
103
+ step={15}
104
+ accessibilityLabel="Viewing time"
105
+ />
106
+ ```
107
+
108
+ | prop | |
109
+ | --- | --- |
110
+ | `value` | 24h `"HH:mm"`, or `null` while empty — fully controlled |
111
+ | `onChange` | the committed time, or `null` once emptied; never fires while typing |
112
+ | `min`, `max` | 24h `"HH:mm"` bounds; a time outside is pulled to the bound |
113
+ | `step` | minutes per arrow press, and the grid a typed time snaps to. Default `1` |
114
+ | `hourFormat` | `'24h'` (default) or `'12h'` — how it is DRAWN; the value stays 24h |
115
+ | `size` | `'small'` (96 × 32) or `'medium'` (default, 104 × 38) |
116
+ | `disabled` | not editable, `aria-disabled`, `DISABLED_OPACITY` |
117
+ | `accessibilityLabel` | **required** — the field draws no label, and `"--:--"` names nothing |
118
+ | `placeholder` | default `"--:--"`, or `"--:-- --"` in 12h |
119
+ | `width`, `style`, `testID` | |
120
+
121
+ **The value is always 24h.** `hourFormat` decides what the reader sees, never
122
+ what the caller receives, so a `TimeField` value drops straight into one of
123
+ `MeetingScheduler`'s `timeSlots` and the app needs no locale to read it back.
124
+
125
+ **It looks like the date chip because it is the same control.** Geometry,
126
+ palette and focus behaviour are `DateChipInput`'s — the `DD/MM/YYYY` chip inside
127
+ the pickers — so a date and a time typed beside each other line up: 1px
128
+ `chipBorder` darkening to `chipBorderFocused` on focus (the border IS the focus
129
+ indicator, `outline-none`), radius 10, shadow-xs, the panel fill.
130
+
131
+ ### Typing
132
+
133
+ The field keeps its own **draft** while you type, so the text is not reformatted
134
+ under the cursor, and commits on blur or submit. Escape reverts to the committed
135
+ value, and so does a draft that does not parse — rather than an error the field
136
+ has no room to draw.
137
+
138
+ Parsing is deliberately forgiving, because a field that only accepts what it
139
+ printed is a field people fight. All of these commit to `09:30`:
140
+
141
+ `9:30` · `930` · `9.30` · `9 30` · `0930` · `9:3` · `9:30 am` · `9:30 a.m.`
142
+
143
+ and `9` alone is `09:00`, `21:30` is `21:30`, `9pm` is `21:00`. An `am`/`pm`
144
+ marker decides the half of the day whatever `hourFormat` says — somebody who
145
+ typed `pm` means pm.
146
+
147
+ `step` snaps a committed time to a grid counted **from midnight**, so `step={15}`
148
+ always means :00 :15 :30 :45. Counting from `min` instead would knock the round
149
+ times off the grid the moment a shop opened at 09:10.
150
+
151
+ ### Keyboard
152
+
153
+ ArrowUp and ArrowDown move by `step` and commit immediately, clamped at `min`
154
+ and `max` rather than wrapping — a wrap from `max` back to `min` reads as the
155
+ field refusing the key. With no value yet, ArrowUp starts at `min` (or 00:00)
156
+ and ArrowDown at `max` (or 23:59).
157
+
158
+ They are a convenience **on a text box**, not a `spinbutton`: the control
159
+ announces itself as the text field it is, named by `accessibilityLabel`, and
160
+ every value the arrows can reach can also be typed. A `spinbutton` role would
161
+ promise `aria-valuenow` over a value that is a string and may be empty.
@@ -84,6 +84,8 @@ and plural rules. Nothing here computes a price.
84
84
  | `badge` | top-left over the photo — a string draws the white pill, any node is placed as given |
85
85
  | `favorite`, `onFavoriteChange` | the heart, top-right; drawn only when `onFavoriteChange` is given |
86
86
  | `onPress`, `href` | see below |
87
+ | `onLongPress`, `onContextMenu` | the press shortcut — see below |
88
+ | `hoverZoom` | web only, off by default: the photo scales 1.06 under the pointer |
87
89
  | `loading` | a skeleton in the same geometry (`aria-busy`) |
88
90
  | `layout` | `'vertical'` (default, photo 20:19) or `'horizontal'` (square photo 40% wide, for lists and map sheets) |
89
91
  | `density` | `'comfortable'` (default) or `'compact'`, a dense list row — see below |
@@ -106,6 +108,32 @@ anchor's default navigation is prevented and `onPress` runs — a router takes i
106
108
  On native, `href` alone opens with `Linking`. With neither the card is not a
107
109
  control.
108
110
 
111
+ **The press shortcut.** `onLongPress` fires on a long press — native, and a
112
+ touch-and-hold on web. `onContextMenu` is its pointer spelling: a right-click on
113
+ web, with the event defaulted for you, which is the same contract
114
+ [`MessageBubble`](/docs/message-bubble) carries. Two props rather than one
115
+ bridged prop, because they are two different events and an app may well want
116
+ only one of them.
117
+
118
+ Two things follow from defaulting the event. The browser's own menu — "open in
119
+ a new tab", "copy link address" — no longer appears **on the card** while
120
+ `onContextMenu` is set, so set it only where the shortcut is worth that. And
121
+ neither event has a keyboard spelling or a name to announce, so whatever they
122
+ open must be reachable another way: the heart, a row in a menu, a button on the
123
+ detail screen. A long press is a shortcut, never the only door.
124
+
125
+ **Hover zoom.** `hoverZoom` scales the photo to 1.06 while the pointer is over
126
+ the card, 400ms, inside the tile that already clips — the photo comes forward,
127
+ the card does not move and the grid does not reflow. It is WEB ONLY and guarded
128
+ by `@media (any-hover: hover)`, so a touch screen reporting a hover it cannot
129
+ deliver is left out, and it does nothing at all under
130
+ `prefers-reduced-motion: reduce`. The compact density ignores it: a 112px
131
+ thumbnail has nothing to come forward from.
132
+
133
+ It is **off by default.** A card that grows under the pointer is a decision an
134
+ app makes for its own grid, not one to turn on under every app already shipping
135
+ this card.
136
+
109
137
  **The controls are siblings of the link, not children.** The heart and arrows are
110
138
  laid over the photo from outside the link: a button inside an anchor is invalid
111
139
  HTML, and would make pressing the heart open the stay. Each is its own tab stop.
@@ -190,6 +218,32 @@ it, two sit side by side, three put one tall photo beside two, four (or more) ma
190
218
  a 2×2 of the first four. The name and `description` sit under it. `onPress` and
191
219
  `href` behave as on `ListingCard`; the name is "Coast weekends, 12 saved".
192
220
 
221
+ | prop | decides |
222
+ | ----------------- | ---------------------------------------------------------------------------------------- |
223
+ | `name` | the collection's name, one line |
224
+ | `description` | the pre-formatted line under it ("12 saved") |
225
+ | `photos` | up to four covers, URLs or `ImageResolver` ids |
226
+ | `icon` | a 16px glyph left of the name — decorative |
227
+ | `color` | the `icon`'s colour, and the empty cover's tint |
228
+ | `empty` | what the cover holds while `photos` is empty |
229
+ | `onPress`, `href` | as on `ListingCard` |
230
+ | `accessibilityLabel`, `style`, `testID` | `testID` derives `-cover`, `-icon`, `-empty` |
231
+
232
+ **The icon goes beside the name, never inside it.** A folder emoji typed into
233
+ `name` is read out by a screen reader as its character, cannot be coloured or
234
+ sized, and counts against the one line the name gets. `icon` is a
235
+ [`BloomIconComponent`](/docs/icons), `aria-hidden`, drawn at 16 in `color` (or
236
+ text-secondary), and the name keeps its own line.
237
+
238
+ `color` is the caller's colour rather than a token, because a collection's
239
+ colour is the person's choice and Bloom has no ramp for it. It paints the glyph
240
+ and tints the cover while the collection is empty.
241
+
242
+ **Empty.** With no photos the cover is the placeholder square, and `empty` is
243
+ centred in it — a glyph, a line, an "add your first save" affordance. Without
244
+ `empty` it is the plain square it has always been, so nothing changes for a
245
+ caller that has not asked for one.
246
+
193
247
  ## Colours
194
248
 
195
249
  All from the theme: the text is `text` / `textSecondary`; the badge pill and the
package/docs/rating.mdx CHANGED
@@ -1,13 +1,13 @@
1
1
  ---
2
2
  title: Rating
3
- description: A compact star, value and review count, and the bar for a rating breakdown.
3
+ description: A compact star, value and review count, the bar for a rating breakdown, and the star picker a review form is written with.
4
4
  order: 61
5
5
  ---
6
6
 
7
7
  # Rating
8
8
 
9
9
  ```tsx
10
- import { Rating, RatingBar } from '@oxy.so/bloom/rating';
10
+ import { Rating, RatingBar, RatingInput } from '@oxy.so/bloom/rating';
11
11
 
12
12
  <Rating value={4.92} count={128} /> // ★ 4.92 (128)
13
13
  <Rating value={4.81} count={214} countStyle="reviews" /> // ★ 4.81 · 214 reviews
@@ -15,8 +15,13 @@ import { Rating, RatingBar } from '@oxy.so/bloom/rating';
15
15
 
16
16
  <RatingBar label="Cleanliness" value={4.9} display="4.9" />
17
17
  <RatingBar label="5" labelWidth={12} value={0.86} max={1} display="86%" />
18
+
19
+ const [stars, setStars] = useState<number | null>(null);
20
+ <RatingInput value={stars} onChange={setStars} accessibilityLabel="Overall rating" />
18
21
  ```
19
22
 
23
+ `Rating` shows a rating that exists; `RatingInput` is how one comes to exist.
24
+
20
25
  ## `Rating`
21
26
 
22
27
  | prop | decides |
@@ -53,3 +58,48 @@ measurement — and a rating breakdown is not a chart: the rows do not encode
53
58
  different categories by colour, they all say "how good is this, out of 5". It is
54
59
  a `progressbar` carrying `aria-valuemin`, `aria-valuemax`, `aria-valuenow` and
55
60
  `aria-valuetext` (from `display`).
61
+
62
+ ## `RatingInput`
63
+
64
+ The picker for a review form. Fully controlled: `value` is `1..max` or `null`
65
+ while nothing is chosen, and `onChange` fires with the star that was chosen.
66
+
67
+ | prop | decides |
68
+ | -------------------- | -------------------------------------------------------------------------------------------- |
69
+ | `value` | the chosen rating, or `null` for nothing chosen yet |
70
+ | `onChange` | called with the chosen rating; never called with the value already chosen |
71
+ | `max` | how many stars, default `5` |
72
+ | `size` | `'small'` (24px star), `'medium'` (default, 32) or `'large'` (40) |
73
+ | `disabled` | no press, no keyboard, the group at `DISABLED_OPACITY` |
74
+ | `accessibilityLabel` | **required** — the name of what is being rated ("Overall rating") |
75
+ | `formatStarLabel` | each star's own name, default `"1 star"` / `"4 stars"` — translate it |
76
+ | `style`, `testID` | `testID` derives `-star-<n>` |
77
+
78
+ Chosen stars fill with the ACCENT, the rest are `RiStarLine` outlines in the
79
+ neutral ramp — the same reasoning as [`RatingBar`](#ratingbar)'s fill: a chosen
80
+ star is an answer somebody gave, and text-primary reads as ink rather than as
81
+ one. Under a pointer the stars up to the hovered one fill as a preview, which
82
+ never touches `value`.
83
+
84
+ ### Operating it without a mouse
85
+
86
+ The group is a `radiogroup` named by `accessibilityLabel`, holding one `radio`
87
+ per star named by `formatStarLabel`. Exactly one of N is what a rating IS, so
88
+ `radio` is the role rather than a `slider` — and it is the only one that gives
89
+ each star its own touch target and its own name to announce.
90
+
91
+ The tab stop roves: the chosen star is the one in the tab order, or the first
92
+ star while nothing is chosen, so one Tab reaches the group either way. Inside
93
+ it, ArrowRight/ArrowUp, ArrowLeft/ArrowDown, Home and End move **and** choose,
94
+ which is how an ARIA radio group behaves. The focus ring is drawn on the star.
95
+
96
+ ### No half stars, and no clearing
97
+
98
+ Half a star doubles the keyboard stops and halves the target — 16px of a 32px
99
+ star, under any touch floor — and the icon set has no half-star glyph, so the
100
+ state could not be drawn honestly either. `Rating` still DISPLAYS fractional
101
+ values (`4.92`): a display has no target and no keyboard.
102
+
103
+ Clearing is left out for the reason re-choosing a [`Radio`](/docs/radio) is a
104
+ no-op — a radio has no "off". A form that has no rating yet holds `null`; it
105
+ does not need the group to express a state it cannot draw.
@@ -118,6 +118,38 @@ It is a `listbox` of `option`s with `aria-selected` on the highlighted row. The
118
118
  list takes focus on web and answers ArrowDown/ArrowUp (wrapping) and Enter;
119
119
  hover moves the same highlight. The rows stay out of the tab order.
120
120
 
121
+ **A row that cannot be chosen.** `disabled` on an item dims it to
122
+ `tokens.DISABLED_OPACITY`, gives it `aria-disabled`, refuses press and Enter,
123
+ keeps hover from highlighting it, and makes the arrow keys step OVER it —
124
+ ArrowDown from the row above lands past it, and a list where every row is
125
+ disabled leaves the highlight alone rather than parking it on a row that answers
126
+ nothing.
127
+
128
+ `disabledReason` is the line that says WHY ("Location is off"). It replaces
129
+ `description` while the row is disabled and is read after the title, so the row
130
+ announces "Use my location, Location is off".
131
+
132
+ ```tsx
133
+ <DestinationSuggestions
134
+ items={[
135
+ {
136
+ id: 'here',
137
+ title: 'Use my location',
138
+ icon: RiFocus3Line,
139
+ disabled: !locationOn,
140
+ disabledReason: 'Location is off',
141
+ },
142
+ { id: 'lisbon', title: 'Lisbon', description: 'Portugal' },
143
+ ]}
144
+ onSelect={choose}
145
+ />
146
+ ```
147
+
148
+ The row stays in the list rather than disappearing, and it is drawn by the list
149
+ rather than beside it: a row that vanishes teaches nothing, and one hand-drawn
150
+ outside the `listbox` is not an `option` — it breaks the arrow keys and the
151
+ "2 of 5" a screen reader counts.
152
+
121
153
  ### GuestPicker
122
154
 
123
155
  `StepperRow`s for adults, children, infants and pets, with hairlines between
@@ -125,6 +157,12 @@ rows. `value: { adults, children, infants, pets }`, `onChange`, `max` (one
125
157
  number or per kind), `kinds` (which rows, in order), `labels`, `descriptions`
126
158
  (`null` hides one).
127
159
 
160
+ `decrementLabel` and `incrementLabel` name every row's `−` and `+` buttons —
161
+ `Stepper`'s own prop names, forwarded to all of them. They default to the
162
+ English `"Decrease"` / `"Increase"`, and they are the last two words in this
163
+ family that a translated app could not reach. The row's title is what says
164
+ which counter a button belongs to, so one pair covers every row.
165
+
128
166
  It is the ONE guests picker: the search bar's "Who" panel and `BookingCard`'s
129
167
  guests popover both use it. For the booking side:
130
168