@oxy.so/bloom 4.17.0 → 4.18.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 (191) hide show
  1. package/docs/adoption-matrix.mdx +4 -4
  2. package/docs/breadcrumb.mdx +11 -0
  3. package/docs/cart-panel.mdx +4 -2
  4. package/docs/checkout-summary.mdx +4 -0
  5. package/docs/chip.mdx +14 -1
  6. package/docs/dialog.mdx +5 -4
  7. package/docs/loading.mdx +11 -0
  8. package/docs/price-breakdown.mdx +17 -0
  9. package/docs/rating.mdx +22 -0
  10. package/docs/stepper.mdx +25 -0
  11. package/lib/commonjs/breadcrumb/Breadcrumb.js +36 -6
  12. package/lib/commonjs/breadcrumb/Breadcrumb.js.map +1 -1
  13. package/lib/commonjs/cart-panel/CartLine.js +32 -10
  14. package/lib/commonjs/cart-panel/CartLine.js.map +1 -1
  15. package/lib/commonjs/cart-panel/CartPanel.js +3 -0
  16. package/lib/commonjs/cart-panel/CartPanel.js.map +1 -1
  17. package/lib/commonjs/cart-panel/shared.js +2 -0
  18. package/lib/commonjs/cart-panel/shared.js.map +1 -1
  19. package/lib/commonjs/checkout-summary/CheckoutConfirm.js +11 -1
  20. package/lib/commonjs/checkout-summary/CheckoutConfirm.js.map +1 -1
  21. package/lib/commonjs/chip/Chip.js +3 -1
  22. package/lib/commonjs/chip/Chip.js.map +1 -1
  23. package/lib/commonjs/dialog/Dialog.js +15 -6
  24. package/lib/commonjs/dialog/Dialog.js.map +1 -1
  25. package/lib/commonjs/dialog/Dialog.web.js +11 -5
  26. package/lib/commonjs/dialog/Dialog.web.js.map +1 -1
  27. package/lib/commonjs/dialog/placement.js +18 -1
  28. package/lib/commonjs/dialog/placement.js.map +1 -1
  29. package/lib/commonjs/loading/Loading.js +7 -3
  30. package/lib/commonjs/loading/Loading.js.map +1 -1
  31. package/lib/commonjs/loading/Loading.web.js +7 -3
  32. package/lib/commonjs/loading/Loading.web.js.map +1 -1
  33. package/lib/commonjs/loading/LoadingBase.js +49 -2
  34. package/lib/commonjs/loading/LoadingBase.js.map +1 -1
  35. package/lib/commonjs/price-breakdown/PriceSummary.js +11 -1
  36. package/lib/commonjs/price-breakdown/PriceSummary.js.map +1 -1
  37. package/lib/commonjs/price-breakdown/PriceSummaryLine.js +13 -1
  38. package/lib/commonjs/price-breakdown/PriceSummaryLine.js.map +1 -1
  39. package/lib/commonjs/rating/Rating.js +90 -4
  40. package/lib/commonjs/rating/Rating.js.map +1 -1
  41. package/lib/commonjs/stepper/Stepper.js +18 -5
  42. package/lib/commonjs/stepper/Stepper.js.map +1 -1
  43. package/lib/module/breadcrumb/Breadcrumb.js +36 -6
  44. package/lib/module/breadcrumb/Breadcrumb.js.map +1 -1
  45. package/lib/module/cart-panel/CartLine.js +32 -10
  46. package/lib/module/cart-panel/CartLine.js.map +1 -1
  47. package/lib/module/cart-panel/CartPanel.js +3 -0
  48. package/lib/module/cart-panel/CartPanel.js.map +1 -1
  49. package/lib/module/cart-panel/shared.js +2 -0
  50. package/lib/module/cart-panel/shared.js.map +1 -1
  51. package/lib/module/checkout-summary/CheckoutConfirm.js +11 -1
  52. package/lib/module/checkout-summary/CheckoutConfirm.js.map +1 -1
  53. package/lib/module/chip/Chip.js +3 -1
  54. package/lib/module/chip/Chip.js.map +1 -1
  55. package/lib/module/dialog/Dialog.js +16 -7
  56. package/lib/module/dialog/Dialog.js.map +1 -1
  57. package/lib/module/dialog/Dialog.web.js +12 -6
  58. package/lib/module/dialog/Dialog.web.js.map +1 -1
  59. package/lib/module/dialog/placement.js +17 -1
  60. package/lib/module/dialog/placement.js.map +1 -1
  61. package/lib/module/loading/Loading.js +8 -4
  62. package/lib/module/loading/Loading.js.map +1 -1
  63. package/lib/module/loading/Loading.web.js +8 -4
  64. package/lib/module/loading/Loading.web.js.map +1 -1
  65. package/lib/module/loading/LoadingBase.js +49 -3
  66. package/lib/module/loading/LoadingBase.js.map +1 -1
  67. package/lib/module/price-breakdown/PriceSummary.js +11 -1
  68. package/lib/module/price-breakdown/PriceSummary.js.map +1 -1
  69. package/lib/module/price-breakdown/PriceSummaryLine.js +13 -1
  70. package/lib/module/price-breakdown/PriceSummaryLine.js.map +1 -1
  71. package/lib/module/rating/Rating.js +89 -4
  72. package/lib/module/rating/Rating.js.map +1 -1
  73. package/lib/module/stepper/Stepper.js +18 -5
  74. package/lib/module/stepper/Stepper.js.map +1 -1
  75. package/lib/typescript/commonjs/breadcrumb/Breadcrumb.d.ts.map +1 -1
  76. package/lib/typescript/commonjs/breadcrumb/types.d.ts +7 -0
  77. package/lib/typescript/commonjs/breadcrumb/types.d.ts.map +1 -1
  78. package/lib/typescript/commonjs/cart-panel/CartLine.d.ts +10 -5
  79. package/lib/typescript/commonjs/cart-panel/CartLine.d.ts.map +1 -1
  80. package/lib/typescript/commonjs/cart-panel/CartPanel.d.ts +1 -1
  81. package/lib/typescript/commonjs/cart-panel/CartPanel.d.ts.map +1 -1
  82. package/lib/typescript/commonjs/cart-panel/shared.d.ts +1 -1
  83. package/lib/typescript/commonjs/cart-panel/shared.d.ts.map +1 -1
  84. package/lib/typescript/commonjs/cart-panel/types.d.ts +18 -0
  85. package/lib/typescript/commonjs/cart-panel/types.d.ts.map +1 -1
  86. package/lib/typescript/commonjs/checkout-summary/CheckoutConfirm.d.ts +4 -1
  87. package/lib/typescript/commonjs/checkout-summary/CheckoutConfirm.d.ts.map +1 -1
  88. package/lib/typescript/commonjs/checkout-summary/types.d.ts +6 -0
  89. package/lib/typescript/commonjs/checkout-summary/types.d.ts.map +1 -1
  90. package/lib/typescript/commonjs/chip/Chip.d.ts.map +1 -1
  91. package/lib/typescript/commonjs/chip/types.d.ts +8 -6
  92. package/lib/typescript/commonjs/chip/types.d.ts.map +1 -1
  93. package/lib/typescript/commonjs/dialog/Dialog.d.ts.map +1 -1
  94. package/lib/typescript/commonjs/dialog/Dialog.web.d.ts.map +1 -1
  95. package/lib/typescript/commonjs/dialog/placement.d.ts +13 -2
  96. package/lib/typescript/commonjs/dialog/placement.d.ts.map +1 -1
  97. package/lib/typescript/commonjs/dialog/types.d.ts +3 -2
  98. package/lib/typescript/commonjs/dialog/types.d.ts.map +1 -1
  99. package/lib/typescript/commonjs/loading/Loading.d.ts.map +1 -1
  100. package/lib/typescript/commonjs/loading/Loading.web.d.ts.map +1 -1
  101. package/lib/typescript/commonjs/loading/LoadingBase.d.ts +18 -0
  102. package/lib/typescript/commonjs/loading/LoadingBase.d.ts.map +1 -1
  103. package/lib/typescript/commonjs/loading/types.d.ts +11 -0
  104. package/lib/typescript/commonjs/loading/types.d.ts.map +1 -1
  105. package/lib/typescript/commonjs/price-breakdown/PriceSummary.d.ts.map +1 -1
  106. package/lib/typescript/commonjs/price-breakdown/PriceSummaryLine.d.ts +3 -1
  107. package/lib/typescript/commonjs/price-breakdown/PriceSummaryLine.d.ts.map +1 -1
  108. package/lib/typescript/commonjs/price-breakdown/types.d.ts +12 -0
  109. package/lib/typescript/commonjs/price-breakdown/types.d.ts.map +1 -1
  110. package/lib/typescript/commonjs/rating/Rating.d.ts +3 -1
  111. package/lib/typescript/commonjs/rating/Rating.d.ts.map +1 -1
  112. package/lib/typescript/commonjs/rating/index.d.ts +1 -1
  113. package/lib/typescript/commonjs/rating/index.d.ts.map +1 -1
  114. package/lib/typescript/commonjs/rating/types.d.ts +20 -0
  115. package/lib/typescript/commonjs/rating/types.d.ts.map +1 -1
  116. package/lib/typescript/commonjs/stepper/Stepper.d.ts +1 -1
  117. package/lib/typescript/commonjs/stepper/Stepper.d.ts.map +1 -1
  118. package/lib/typescript/commonjs/stepper/types.d.ts +9 -0
  119. package/lib/typescript/commonjs/stepper/types.d.ts.map +1 -1
  120. package/lib/typescript/module/breadcrumb/Breadcrumb.d.ts.map +1 -1
  121. package/lib/typescript/module/breadcrumb/types.d.ts +7 -0
  122. package/lib/typescript/module/breadcrumb/types.d.ts.map +1 -1
  123. package/lib/typescript/module/cart-panel/CartLine.d.ts +10 -5
  124. package/lib/typescript/module/cart-panel/CartLine.d.ts.map +1 -1
  125. package/lib/typescript/module/cart-panel/CartPanel.d.ts +1 -1
  126. package/lib/typescript/module/cart-panel/CartPanel.d.ts.map +1 -1
  127. package/lib/typescript/module/cart-panel/shared.d.ts +1 -1
  128. package/lib/typescript/module/cart-panel/shared.d.ts.map +1 -1
  129. package/lib/typescript/module/cart-panel/types.d.ts +18 -0
  130. package/lib/typescript/module/cart-panel/types.d.ts.map +1 -1
  131. package/lib/typescript/module/checkout-summary/CheckoutConfirm.d.ts +4 -1
  132. package/lib/typescript/module/checkout-summary/CheckoutConfirm.d.ts.map +1 -1
  133. package/lib/typescript/module/checkout-summary/types.d.ts +6 -0
  134. package/lib/typescript/module/checkout-summary/types.d.ts.map +1 -1
  135. package/lib/typescript/module/chip/Chip.d.ts.map +1 -1
  136. package/lib/typescript/module/chip/types.d.ts +8 -6
  137. package/lib/typescript/module/chip/types.d.ts.map +1 -1
  138. package/lib/typescript/module/dialog/Dialog.d.ts.map +1 -1
  139. package/lib/typescript/module/dialog/Dialog.web.d.ts.map +1 -1
  140. package/lib/typescript/module/dialog/placement.d.ts +13 -2
  141. package/lib/typescript/module/dialog/placement.d.ts.map +1 -1
  142. package/lib/typescript/module/dialog/types.d.ts +3 -2
  143. package/lib/typescript/module/dialog/types.d.ts.map +1 -1
  144. package/lib/typescript/module/loading/Loading.d.ts.map +1 -1
  145. package/lib/typescript/module/loading/Loading.web.d.ts.map +1 -1
  146. package/lib/typescript/module/loading/LoadingBase.d.ts +18 -0
  147. package/lib/typescript/module/loading/LoadingBase.d.ts.map +1 -1
  148. package/lib/typescript/module/loading/types.d.ts +11 -0
  149. package/lib/typescript/module/loading/types.d.ts.map +1 -1
  150. package/lib/typescript/module/price-breakdown/PriceSummary.d.ts.map +1 -1
  151. package/lib/typescript/module/price-breakdown/PriceSummaryLine.d.ts +3 -1
  152. package/lib/typescript/module/price-breakdown/PriceSummaryLine.d.ts.map +1 -1
  153. package/lib/typescript/module/price-breakdown/types.d.ts +12 -0
  154. package/lib/typescript/module/price-breakdown/types.d.ts.map +1 -1
  155. package/lib/typescript/module/rating/Rating.d.ts +3 -1
  156. package/lib/typescript/module/rating/Rating.d.ts.map +1 -1
  157. package/lib/typescript/module/rating/index.d.ts +1 -1
  158. package/lib/typescript/module/rating/index.d.ts.map +1 -1
  159. package/lib/typescript/module/rating/types.d.ts +20 -0
  160. package/lib/typescript/module/rating/types.d.ts.map +1 -1
  161. package/lib/typescript/module/stepper/Stepper.d.ts +1 -1
  162. package/lib/typescript/module/stepper/Stepper.d.ts.map +1 -1
  163. package/lib/typescript/module/stepper/types.d.ts +9 -0
  164. package/lib/typescript/module/stepper/types.d.ts.map +1 -1
  165. package/package.json +1 -1
  166. package/src/breadcrumb/Breadcrumb.tsx +29 -4
  167. package/src/breadcrumb/types.ts +7 -0
  168. package/src/cart-panel/CartLine.tsx +30 -7
  169. package/src/cart-panel/CartPanel.tsx +3 -0
  170. package/src/cart-panel/shared.ts +11 -1
  171. package/src/cart-panel/types.ts +18 -0
  172. package/src/checkout-summary/CheckoutConfirm.tsx +9 -0
  173. package/src/checkout-summary/types.ts +6 -0
  174. package/src/chip/Chip.tsx +3 -1
  175. package/src/chip/types.ts +8 -6
  176. package/src/dialog/Dialog.tsx +19 -8
  177. package/src/dialog/Dialog.web.tsx +15 -7
  178. package/src/dialog/placement.ts +19 -2
  179. package/src/dialog/types.ts +3 -2
  180. package/src/loading/Loading.tsx +6 -2
  181. package/src/loading/Loading.web.tsx +6 -2
  182. package/src/loading/LoadingBase.tsx +55 -5
  183. package/src/loading/types.ts +11 -0
  184. package/src/price-breakdown/PriceSummary.tsx +10 -0
  185. package/src/price-breakdown/PriceSummaryLine.tsx +12 -0
  186. package/src/price-breakdown/types.ts +12 -0
  187. package/src/rating/Rating.tsx +87 -3
  188. package/src/rating/index.ts +1 -0
  189. package/src/rating/types.ts +21 -0
  190. package/src/stepper/Stepper.tsx +18 -5
  191. package/src/stepper/types.ts +9 -0
@@ -21,12 +21,17 @@ import type { CartLineProps } from './types';
21
21
  * leading the square photo (56; 44 compact), washed while it is sold out
22
22
  * body the name; the chosen options on one quiet line; the note; then
23
23
  * the quantity control and remove, side by side
24
- * trailing the price, right-aligned, with an optional struck original
24
+ * trailing the price, right-aligned, with an optional struck original, and
25
+ * an optional `secondaryPrice` (a second currency) under it in the
26
+ * secondary colour
25
27
  *
26
- * THE STEPPER DOES NOT REMOVE. Its floor is 1, and removing is its own control
27
- * with its own name — a stepper that deletes the line when you press `−` once
28
- * too often is a destructive action behind an arithmetic one, and it has no
29
- * label saying so.
28
+ * BY DEFAULT THE STEPPER DOES NOT REMOVE. Its floor is 1, and removing is its
29
+ * own control with its own name — a stepper that deletes the line when you
30
+ * press `−` once too often is a destructive action behind an arithmetic one.
31
+ * `removeInStepper` opts into the storefront pattern instead: at 1 the `−`
32
+ * becomes a trash button NAMED as removal (`Stepper`'s `onRemove`), and the
33
+ * separate remove control is not drawn. A sold-out line keeps the separate
34
+ * control, because its stepper is disabled and could not remove anything.
30
35
  *
31
36
  * The row is never pressable, which is what lets the controls live in it: a
32
37
  * pressable `Item` is a real `<button>` on web and a control inside one is
@@ -48,6 +53,7 @@ function CartLineComponent(props: CartLineProps) {
48
53
  note,
49
54
  price,
50
55
  originalPrice,
56
+ secondaryPrice,
51
57
  quantity,
52
58
  photo,
53
59
  photoVariant,
@@ -56,6 +62,7 @@ function CartLineComponent(props: CartLineProps) {
56
62
  onQuantityChange,
57
63
  onRemove,
58
64
  removeLabel,
65
+ removeInStepper = false,
59
66
  density = 'comfortable',
60
67
  accessibilityLabel,
61
68
  style,
@@ -68,6 +75,10 @@ function CartLineComponent(props: CartLineProps) {
68
75
  const g = CART_GEOMETRY[density];
69
76
  const compact = density === 'compact';
70
77
  const chosen = optionsLine(options);
78
+ const removeName = removeLabel ?? `Remove ${name}`;
79
+ // The stepper carries removal only when it is live; a disabled stepper
80
+ // (sold out) cannot, and the line still needs its remove control.
81
+ const stepperRemoves = removeInStepper && onRemove !== undefined && onQuantityChange !== undefined && !unavailable;
71
82
 
72
83
  return (
73
84
  <Item
@@ -95,6 +106,16 @@ function CartLineComponent(props: CartLineProps) {
95
106
  secondaryColor={paint.textSecondary}
96
107
  testID={testID ? `${testID}-price` : undefined}
97
108
  />
109
+ {secondaryPrice ? (
110
+ <Text
111
+ variant={compact ? 'caption-1-regular' : 'body-2-regular'}
112
+ numberOfLines={1}
113
+ testID={testID ? `${testID}-secondary-price` : undefined}
114
+ style={{ color: paint.textSecondary, fontVariant: ['tabular-nums'] }}
115
+ >
116
+ {secondaryPrice}
117
+ </Text>
118
+ ) : null}
98
119
  </View>
99
120
  }
100
121
  style={style}
@@ -146,6 +167,8 @@ function CartLineComponent(props: CartLineProps) {
146
167
  min={1}
147
168
  disabled={unavailable}
148
169
  onValueChange={onQuantityChange}
170
+ onRemove={stepperRemoves ? onRemove : undefined}
171
+ removeLabel={stepperRemoves ? removeName : undefined}
149
172
  accessibilityLabel={name}
150
173
  testID={testID ? `${testID}-stepper` : undefined}
151
174
  />
@@ -158,11 +181,11 @@ function CartLineComponent(props: CartLineProps) {
158
181
  {`×${quantity}`}
159
182
  </Text>
160
183
  )}
161
- {onRemove ? (
184
+ {onRemove && !stepperRemoves ? (
162
185
  <GlyphButton
163
186
  size={32}
164
187
  icon={RiDeleteBinLine}
165
- accessibilityLabel={removeLabel ?? `Remove ${name}`}
188
+ accessibilityLabel={removeName}
166
189
  onPress={onRemove}
167
190
  testID={testID ? `${testID}-remove` : undefined}
168
191
  />
@@ -187,6 +187,7 @@ function CartPanelComponent({
187
187
  lines,
188
188
  onLineQuantityChange,
189
189
  onLineRemove,
190
+ removeInStepper = false,
190
191
  minimumOrder,
191
192
  tip,
192
193
  promo,
@@ -239,6 +240,7 @@ function CartPanelComponent({
239
240
  note={line.note}
240
241
  price={line.price}
241
242
  originalPrice={line.originalPrice}
243
+ secondaryPrice={line.secondaryPrice}
242
244
  quantity={line.quantity}
243
245
  photo={line.photo}
244
246
  photoVariant={line.photoVariant}
@@ -251,6 +253,7 @@ function CartPanelComponent({
251
253
  : undefined
252
254
  }
253
255
  onRemove={onLineRemove ? () => onLineRemove(line.id) : undefined}
256
+ removeInStepper={removeInStepper}
254
257
  testID={testID ? `${testID}-line-${line.id}` : undefined}
255
258
  />
256
259
  ))}
@@ -45,7 +45,15 @@ export function optionsLine(options: ReadonlyArray<string> | undefined): string
45
45
  export function composeCartLineName(
46
46
  line: Pick<
47
47
  CartLineEntry,
48
- 'name' | 'options' | 'note' | 'quantity' | 'price' | 'originalPrice' | 'unavailable' | 'unavailableLabel'
48
+ | 'name'
49
+ | 'options'
50
+ | 'note'
51
+ | 'quantity'
52
+ | 'price'
53
+ | 'originalPrice'
54
+ | 'secondaryPrice'
55
+ | 'unavailable'
56
+ | 'unavailableLabel'
49
57
  >,
50
58
  ): string {
51
59
  const parts: string[] = [line.name];
@@ -55,6 +63,8 @@ export function composeCartLineName(
55
63
  parts.push(String(line.quantity));
56
64
  if (line.originalPrice) parts.push(`${line.price}, originally ${line.originalPrice}`);
57
65
  else parts.push(line.price);
66
+ // The second currency travels with the price it restates.
67
+ if (line.secondaryPrice) parts.push(line.secondaryPrice);
58
68
  if (line.unavailable) parts.push(line.unavailableLabel ?? 'Sold out');
59
69
  return parts.join(', ');
60
70
  }
@@ -26,6 +26,12 @@ export interface CartLineEntry {
26
26
  price: string;
27
27
  /** PRE-FORMATTED price before a discount, struck through BEFORE `price`. */
28
28
  originalPrice?: string;
29
+ /**
30
+ * A second, PRE-FORMATTED rendering of `price` — the buyer's currency beside
31
+ * the shop's, `"≈ 12,00 €"` — drawn de-emphasised under it. The cart's
32
+ * spelling of `ListingPriceLine.secondary`; drawn as given, nothing converts.
33
+ */
34
+ secondaryPrice?: string;
29
35
  /** How many. Always at least 1 — a line with none of it is a line that was removed. */
30
36
  quantity: number;
31
37
  /** An absolute URL, or an id the app's `ImageResolver` turns into one. */
@@ -45,6 +51,13 @@ export interface CartLineProps extends Omit<CartLineEntry, 'id'> {
45
51
  onRemove?: () => void;
46
52
  /** Names the remove control, which draws no text. Default `"Remove <name>"`. */
47
53
  removeLabel?: string;
54
+ /**
55
+ * Moves removal INTO the stepper: at quantity 1 its `−` becomes a trash button
56
+ * named `removeLabel` that calls `onRemove`, and no separate remove control is
57
+ * drawn. Needs both `onQuantityChange` and `onRemove`; a sold-out line keeps
58
+ * the separate control. Default `false` — the stepper floors at 1.
59
+ */
60
+ removeInStepper?: boolean;
48
61
  /** Default `comfortable`. */
49
62
  density?: CartDensity;
50
63
  /** Replaces the composed name ("Ember flatbread, Large, Extra cheese, 2, €24.00"). */
@@ -151,6 +164,11 @@ export interface CartPanelProps {
151
164
  onLineQuantityChange?: (id: string, quantity: number) => void;
152
165
  /** With it every line draws a remove control. */
153
166
  onLineRemove?: (id: string) => void;
167
+ /**
168
+ * Every line removes from its stepper at quantity 1 instead of drawing a
169
+ * separate remove control (`CartLine`'s `removeInStepper`). Default `false`.
170
+ */
171
+ removeInStepper?: boolean;
154
172
 
155
173
  /** The shortfall warning. Drawn above the totals, where the number it is about is. */
156
174
  minimumOrder?: CartMinimumOrder;
@@ -15,6 +15,9 @@ import type { CheckoutConfirmProps } from './types';
15
15
  * terms under it, and the state where it must not fire twice.
16
16
  *
17
17
  * button `Button` primary large, full width, "Place order · €24.80"
18
+ * second `secondaryAmount` ("≈ 12,00 €"), 8 under the button, the same
19
+ * centred secondary line as the terms — the button carries ONE
20
+ * amount, the one the charge is made in
18
21
  * terms 12 under it; the centred secondary line every Bloom action card
19
22
  * draws under its primary (`ActionCardNote`)
20
23
  * footer 16 under the terms
@@ -44,6 +47,7 @@ import type { CheckoutConfirmProps } from './types';
44
47
  function CheckoutConfirmComponent({
45
48
  label = CHECKOUT_CONFIRM_LABEL,
46
49
  amount,
50
+ secondaryAmount,
47
51
  terms,
48
52
  onConfirm,
49
53
  disabled = false,
@@ -90,6 +94,11 @@ function CheckoutConfirmComponent({
90
94
  >
91
95
  {words}
92
96
  </Button>
97
+ {secondaryAmount ? (
98
+ <ActionCardNote style={{ marginTop: 8 }} testID={id('secondary-amount')}>
99
+ {secondaryAmount}
100
+ </ActionCardNote>
101
+ ) : null}
93
102
  {terms != null ? (
94
103
  <ActionCardNote style={{ marginTop: 12 }} testID={id('terms')}>
95
104
  {terms}
@@ -89,6 +89,12 @@ export interface CheckoutConfirmProps {
89
89
  * press commits to and the press are one decision.
90
90
  */
91
91
  amount?: string;
92
+ /**
93
+ * A second, PRE-FORMATTED rendering of `amount` — the buyer's currency beside
94
+ * the shop's, `"≈ 12,00 €"` — drawn de-emphasised, centred under the button.
95
+ * Named like `PriceLine.secondaryAmount`; drawn as given, nothing converts.
96
+ */
97
+ secondaryAmount?: string;
92
98
  /**
93
99
  * The terms line under the button — a string, or a node with links in it.
94
100
  * `null` draws none.
package/src/chip/Chip.tsx CHANGED
@@ -242,8 +242,10 @@ const ChipComponent = forwardRef<View, ChipProps>(function ChipComponent(
242
242
  // WHICH attribute is a property of the ROLE, not a preference — a `radio`
243
243
  // carries `aria-checked` and a `tab` `aria-selected`; `aria-pressed` on
244
244
  // either is invalid, and `aria-selected` on a `button` is too.
245
+ // A `checkbox` (a multi-select filter chip) reads `aria-checked` like a
246
+ // `radio`; what differs is that its group allows several at once.
245
247
  const stateProps =
246
- role === 'radio'
248
+ role === 'radio' || role === 'checkbox'
247
249
  ? { accessibilityState: { checked: selected, disabled }, 'aria-checked': selected }
248
250
  : role === 'tab'
249
251
  ? { accessibilityState: { selected, disabled }, 'aria-selected': selected }
package/src/chip/types.ts CHANGED
@@ -26,10 +26,11 @@ export type ChipHue = 'lime' | 'rose' | 'yellow' | 'cyan' | 'blue' | 'purple' |
26
26
 
27
27
  /**
28
28
  * What a pressable chip IS, which decides the state ARIA reads it by:
29
- * a toggle `button` (`aria-pressed`), a `radio` in a radiogroup
30
- * (`aria-checked`), or a `tab` in a tablist (`aria-selected`).
29
+ * a toggle `button` (`aria-pressed`), a `checkbox` in a multi-select filter
30
+ * group (`aria-checked`), a `radio` in a radiogroup (`aria-checked`), or a
31
+ * `tab` in a tablist (`aria-selected`).
31
32
  */
32
- export type ChipRole = 'button' | 'radio' | 'tab';
33
+ export type ChipRole = 'button' | 'checkbox' | 'radio' | 'tab';
33
34
 
34
35
  export interface ChipProps {
35
36
  appearance?: BloomAppearance;
@@ -84,9 +85,10 @@ export interface ChipProps {
84
85
  selected?: boolean;
85
86
  /**
86
87
  * What a pressable chip is to assistive technology. Default `button`
87
- * (`aria-pressed`). Use `radio` inside a `radiogroup` and `tab` inside a
88
- * `tablist` — the state attribute follows the role, and a `radio` announcing
89
- * `aria-pressed` is invalid.
88
+ * (`aria-pressed`). Use `checkbox` for a multi-select filter chip (in a
89
+ * `group`), `radio` inside a `radiogroup` and `tab` inside a `tablist` — the
90
+ * state attribute follows the role, and a `radio` announcing `aria-pressed`
91
+ * is invalid.
90
92
  */
91
93
  role?: ChipRole;
92
94
  /** Whether the chip is disabled. */
@@ -28,6 +28,7 @@ import Animated, {
28
28
  import { BottomSheet, type BottomSheetRef } from '../bottom-sheet';
29
29
  import { Backdrop, OverlayRoot } from '../overlay';
30
30
  import { useTheme } from '../theme/use-theme';
31
+ import { useIsRtl } from '../hooks/use-is-rtl';
31
32
  import { bloomShadowStyle } from '../design-tokens/shadows';
32
33
  import { StyledView } from '../styles/styled-primitives';
33
34
  import { SurfaceLevelProvider } from '../styles/surface-levels';
@@ -51,6 +52,8 @@ import {
51
52
  PANEL_RADIUS,
52
53
  SIDE_SHEET_MIN_GUTTER,
53
54
  useResolvedPlacement,
55
+ physicalDialogSide,
56
+ type DialogSidePlacement,
54
57
  } from './placement';
55
58
  import type {
56
59
  DialogAction,
@@ -137,9 +140,9 @@ function CenteredOrSideDialog({
137
140
  containerClassName,
138
141
  label,
139
142
  children,
140
- }: Omit<DialogProps, 'placement'> & { placement: 'center' | 'left' | 'right' }) {
143
+ }: Omit<DialogProps, 'placement'> & { placement: 'center' | DialogSidePlacement }) {
141
144
  const isControlled = controlledOpen !== undefined;
142
- const isSide = placement === 'left' || placement === 'right';
145
+ const isSide = placement !== 'center';
143
146
  const headerController = useDialogHeaderController();
144
147
  const scrollableResolved = scrollable ?? true;
145
148
  const { height: viewportHeight } = useWindowDimensions();
@@ -422,7 +425,7 @@ function SideSheet({
422
425
  }: {
423
426
  open: boolean;
424
427
  onDismiss: () => void;
425
- side: 'left' | 'right';
428
+ side: DialogSidePlacement;
426
429
  width: number;
427
430
  inset?: DialogInset;
428
431
  dismissOnBackdrop: boolean;
@@ -453,8 +456,16 @@ function SideSheet({
453
456
  const insetBottom = inset?.bottom ?? 0;
454
457
  const insetLeft = inset?.left ?? 0;
455
458
  const insetRight = inset?.right ?? 0;
456
- const anchorInset = side === 'left' ? insetLeft : insetRight;
457
- const oppositeInset = side === 'left' ? insetRight : insetLeft;
459
+ // `start`/`end` resolve to the physical edge they land on for everything a
460
+ // layout engine does not mirror: the slide sign, and which `inset` applies.
461
+ const rtl = useIsRtl();
462
+ const edge = physicalDialogSide(side, rtl);
463
+ // The POSITION of a logical placement is a logical key, resolved by Yoga
464
+ // against `I18nManager` — a physical `left`/`right` here would be swapped by
465
+ // React Native's default `swapLeftAndRightInRTL` and land on the wrong edge.
466
+ const anchorKey = side === 'start' ? 'insetInlineStart' : side === 'end' ? 'insetInlineEnd' : side;
467
+ const anchorInset = edge === 'left' ? insetLeft : insetRight;
468
+ const oppositeInset = edge === 'left' ? insetRight : insetLeft;
458
469
 
459
470
  const sideWidth = useMemo(() => {
460
471
  const available = viewportWidth - anchorInset - oppositeInset - SIDE_SHEET_MIN_GUTTER;
@@ -491,7 +502,7 @@ function SideSheet({
491
502
  if (dismissOnBackdrop) onDismiss();
492
503
  }, [dismissOnBackdrop, onDismiss]);
493
504
 
494
- const hiddenSign = side === 'left' ? -1 : 1;
505
+ const hiddenSign = edge === 'left' ? -1 : 1;
495
506
  const panelAnimatedStyle = useAnimatedStyle(() => {
496
507
  const hidden = 1 - progress.value;
497
508
  return {
@@ -513,11 +524,11 @@ function SideSheet({
513
524
  ({
514
525
  top: insetTop,
515
526
  bottom: insetBottom,
516
- [side]: anchorInset,
527
+ [anchorKey]: anchorInset,
517
528
  width: sideWidth,
518
529
  borderRadius: PANEL_RADIUS,
519
530
  }),
520
- [insetTop, insetBottom, side, anchorInset, sideWidth],
531
+ [insetTop, insetBottom, anchorKey, anchorInset, sideWidth],
521
532
  );
522
533
 
523
534
  if (!mounted) return null;
@@ -45,6 +45,7 @@ import { Z_INDEX } from '../styles/z-index';
45
45
  import { WEB_POSITION_FIXED, type WebCssStyle } from '../styles/web-view-style';
46
46
  import { bloomShadowStyle } from '../design-tokens/shadows';
47
47
  import { useTheme } from '../theme/use-theme';
48
+ import { useIsRtl } from '../hooks/use-is-rtl';
48
49
  import { Context, useDialogContext, useDialogControl } from './context';
49
50
  import { DialogBody } from './DialogContent';
50
51
  import { DialogBottomSheet } from './DialogBottomSheet';
@@ -69,6 +70,8 @@ import {
69
70
  PANEL_RADIUS,
70
71
  SIDE_SHEET_MIN_GUTTER,
71
72
  useResolvedPlacement,
73
+ physicalDialogSide,
74
+ type DialogSidePlacement,
72
75
  } from './placement';
73
76
  import type {
74
77
  DialogAction,
@@ -196,7 +199,7 @@ function CenterOrSideDialog({
196
199
  containerClassName,
197
200
  label,
198
201
  children,
199
- }: Omit<DialogProps, 'placement'> & { placement: 'center' | 'left' | 'right' }) {
202
+ }: Omit<DialogProps, 'placement'> & { placement: 'center' | DialogSidePlacement }) {
200
203
  // Inject the required @keyframes on mount (before the panel ever appears —
201
204
  // this component renders once with `isOpen=false` prior to opening, so the
202
205
  // stylesheet is present by the time the animated surface mounts).
@@ -647,7 +650,7 @@ function SheetSurface({
647
650
  actions?: DialogAction[];
648
651
  header?: DialogProps['header'];
649
652
  scrollable?: boolean;
650
- placement: 'left' | 'right';
653
+ placement: DialogSidePlacement;
651
654
  shown: boolean;
652
655
  width: number;
653
656
  inset?: DialogInset;
@@ -666,6 +669,7 @@ function SheetSurface({
666
669
  const descriptionId = useId();
667
670
  const headerController = useDialogHeaderController();
668
671
  const { width: viewportWidth } = useWindowDimensions();
672
+ const rtl = useIsRtl();
669
673
 
670
674
  // Defer the entry transition by a frame so the start state paints before the
671
675
  // browser animates to `shown`. `entered` is false on the first committed
@@ -707,22 +711,26 @@ function SheetSurface({
707
711
  const insetBottom = inset?.bottom ?? 0;
708
712
  const insetLeft = inset?.left ?? 0;
709
713
  const insetRight = inset?.right ?? 0;
710
- const anchorInset = placement === 'left' ? insetLeft : insetRight;
711
- const oppositeInset = placement === 'left' ? insetRight : insetLeft;
714
+ // `start`/`end` land on a physical edge read from `useIsRtl()`. The drawer
715
+ // is portaled outside any `dir` context, so react-native-web would resolve
716
+ // a logical key left-to-right; the physical edge is the one that is right.
717
+ const edge = physicalDialogSide(placement, rtl);
718
+ const anchorInset = edge === 'left' ? insetLeft : insetRight;
719
+ const oppositeInset = edge === 'left' ? insetRight : insetLeft;
712
720
  const available = viewportWidth - anchorInset - oppositeInset - SIDE_SHEET_MIN_GUTTER;
713
721
  const cappedWidth = Math.max(0, Math.min(width, available));
714
- const hiddenSign = placement === 'left' ? '-100%' : '100%';
722
+ const hiddenSign = edge === 'left' ? '-100%' : '100%';
715
723
 
716
724
  return {
717
725
  top: insetTop,
718
726
  bottom: insetBottom,
719
- [placement]: anchorInset,
727
+ [edge]: anchorInset,
720
728
  width: cappedWidth,
721
729
  borderRadius: PANEL_RADIUS,
722
730
  transform: [{ translateX: visible ? 0 : hiddenSign }],
723
731
  opacity: visible ? 1 : 0,
724
732
  };
725
- }, [visible, placement, width, inset, viewportWidth]);
733
+ }, [visible, placement, rtl, width, inset, viewportWidth]);
726
734
 
727
735
  const handleBackdropPress = useCallback(() => {
728
736
  if (dismissOnBackdrop) onDismiss();
@@ -7,10 +7,27 @@ import { BREAKPOINTS } from '../styles/breakpoints';
7
7
  * Anchor a `<Dialog>` can render against.
8
8
  *
9
9
  * - `'center'` — the default centered modal card (current behavior).
10
- * - `'left'` / `'right'` — an anchored side-sheet / drawer.
10
+ * - `'left'` / `'right'` — an anchored side-sheet / drawer on that PHYSICAL edge.
11
+ * - `'start'` / `'end'` — the same drawer on the LOGICAL edge: `start` is the
12
+ * left in a left-to-right layout and the right in a right-to-left one, and it
13
+ * slides in from that edge. Resolved with `useIsRtl()`.
11
14
  * - `'bottom'` — a bottom-sheet (drag handle, slide-up).
12
15
  */
13
- export type DialogPlacement = 'center' | 'left' | 'right' | 'bottom';
16
+ export type DialogPlacement = 'center' | 'left' | 'right' | 'start' | 'end' | 'bottom';
17
+
18
+ /** The four side-sheet placements, physical and logical. */
19
+ export type DialogSidePlacement = 'left' | 'right' | 'start' | 'end';
20
+
21
+ /**
22
+ * The PHYSICAL edge a side placement lands on. `left`/`right` are themselves;
23
+ * `start` is `left` left-to-right and `right` right-to-left, `end` the reverse.
24
+ * The panel's `translateX` sign, which no layout engine mirrors, follows this.
25
+ */
26
+ export function physicalDialogSide(side: DialogSidePlacement, rtl: boolean): 'left' | 'right' {
27
+ if (side === 'start') return rtl ? 'right' : 'left';
28
+ if (side === 'end') return rtl ? 'left' : 'right';
29
+ return side;
30
+ }
14
31
 
15
32
  /**
16
33
  * A `<Dialog>` placement that may vary by viewport width.
@@ -256,8 +256,9 @@ export type DialogProps = React.PropsWithChildren<{
256
256
  actions?: DialogAction[];
257
257
  /**
258
258
  * Surface anchor. Defaults to `'center'` (the centered modal — current
259
- * behavior). `'left'`/`'right'` render an anchored side-sheet, `'bottom'` a
260
- * bottom-sheet. A responsive map resolves by viewport width, e.g.
259
+ * behavior). `'left'`/`'right'` render an anchored side-sheet on that
260
+ * physical edge; `'start'`/`'end'` on the logical edge (mirrored, with its
261
+ * slide-in, in a right-to-left layout); `'bottom'` a bottom-sheet. A responsive map resolves by viewport width, e.g.
261
262
  * `{ base: 'bottom', md: 'left' }` (drawer on wide, sheet on narrow).
262
263
  */
263
264
  placement?: ResponsiveDialogPlacement;
@@ -18,7 +18,7 @@ import { resolveBloomColors } from '../appearance/colors';
18
18
  import { useBloomAppearance } from '../appearance';
19
19
  import { animation } from '../styles/tokens';
20
20
  import { useTheme } from '../theme/use-theme';
21
- import { bindLoading, SIZE_CONFIG, styles } from './LoadingBase';
21
+ import { bindLoading, LoadingRoot, SIZE_CONFIG, styles } from './LoadingBase';
22
22
  import { SpinnerIcon } from './SpinnerIcon';
23
23
  import type { TopLoadingProps } from './types';
24
24
 
@@ -31,6 +31,7 @@ const TopLoading: React.FC<TopLoadingProps> = ({
31
31
  iconSize,
32
32
  heightOffset = 0,
33
33
  spinnerIcon,
34
+ accessibilityLabel,
34
35
  testID,
35
36
  }) => {
36
37
  const theme = useTheme();
@@ -69,7 +70,10 @@ const TopLoading: React.FC<TopLoadingProps> = ({
69
70
  return (
70
71
  <Animated.View style={[styles.topContainer, containerAnimated]} testID={testID}>
71
72
  <Animated.View style={[styles.topLoadingView, { height: targetHeight }, innerAnimated, style]}>
72
- {spinnerIcon ?? <SpinnerIcon size={effectiveIconSize} color={spinnerColor} />}
73
+ {/* Named only while it is showing: a collapsed bar is not a wait. */}
74
+ <LoadingRoot accessibilityLabel={showLoading ? accessibilityLabel : undefined}>
75
+ {spinnerIcon ?? <SpinnerIcon size={effectiveIconSize} color={spinnerColor} />}
76
+ </LoadingRoot>
73
77
  </Animated.View>
74
78
  </Animated.View>
75
79
  );
@@ -16,7 +16,7 @@ import { useBloomAppearance } from '../appearance';
16
16
  import { animation } from '../styles/tokens';
17
17
  import type { WebCssStyle } from '../styles/web-view-style';
18
18
  import { useTheme } from '../theme/use-theme';
19
- import { bindLoading, SIZE_CONFIG, styles } from './LoadingBase';
19
+ import { bindLoading, LoadingRoot, SIZE_CONFIG, styles } from './LoadingBase';
20
20
  import { SpinnerIcon } from './SpinnerIcon.web';
21
21
  import type { TopLoadingProps } from './types';
22
22
 
@@ -41,6 +41,7 @@ const TopLoading: React.FC<TopLoadingProps> = ({
41
41
  iconSize,
42
42
  heightOffset = 0,
43
43
  spinnerIcon,
44
+ accessibilityLabel,
44
45
  testID,
45
46
  }) => {
46
47
  const theme = useTheme();
@@ -84,7 +85,10 @@ const TopLoading: React.FC<TopLoadingProps> = ({
84
85
  style,
85
86
  ]}
86
87
  >
87
- {spinnerIcon ?? <SpinnerIcon size={effectiveIconSize} color={spinnerColor} />}
88
+ {/* Named only while it is showing: a collapsed bar is not a wait. */}
89
+ <LoadingRoot accessibilityLabel={showLoading ? accessibilityLabel : undefined}>
90
+ {spinnerIcon ?? <SpinnerIcon size={effectiveIconSize} color={spinnerColor} />}
91
+ </LoadingRoot>
88
92
  </View>
89
93
  </View>
90
94
  );
@@ -23,7 +23,7 @@ import { normalizeBloomSize } from '../appearance/legacy';
23
23
  import { resolveBloomColors } from '../appearance/colors';
24
24
  import { useBloomAppearance } from '../appearance';
25
25
  import React, { memo } from 'react';
26
- import { View, Text, StyleSheet } from 'react-native';
26
+ import { View, Text, StyleSheet, type StyleProp, type ViewStyle } from 'react-native';
27
27
 
28
28
  import { useTheme } from '../theme/use-theme';
29
29
  import type {
@@ -41,6 +41,54 @@ export const SIZE_CONFIG = {
41
41
  lg: { spinner: 44, text: 16 },
42
42
  } as const;
43
43
 
44
+ /**
45
+ * The indicator's root: an indeterminate, NAMED `progressbar` when the caller
46
+ * gave `accessibilityLabel`, a plain view (today's behaviour) when not.
47
+ *
48
+ * Flat `aria-*` because react-native-web drops `accessibilityState` /
49
+ * `accessibilityValue`; React Native folds them back. `aria-valuenow` is
50
+ * deliberately absent (`undefined`): that is what makes a progressbar
51
+ * INDETERMINATE in ARIA, and a spinner has no amount done to report. The role
52
+ * is a literal on its own branch so `aria-state-source-census.test.ts` can read
53
+ * it; a computed role would be invisible to it.
54
+ */
55
+ export function LoadingRoot({
56
+ accessibilityLabel,
57
+ style,
58
+ testID,
59
+ children,
60
+ }: {
61
+ accessibilityLabel?: string;
62
+ style?: StyleProp<ViewStyle>;
63
+ testID?: string;
64
+ children: React.ReactNode;
65
+ }) {
66
+ if (accessibilityLabel) {
67
+ return (
68
+ <View
69
+ role="progressbar"
70
+ accessible
71
+ accessibilityLabel={accessibilityLabel}
72
+ aria-valuemin={0}
73
+ aria-valuemax={100}
74
+ aria-valuenow={undefined}
75
+ aria-busy
76
+ style={style}
77
+ testID={testID}
78
+ >
79
+ {children}
80
+ </View>
81
+ );
82
+ }
83
+ // Nothing to carry (the `top` variant's inner slot): no extra node at all.
84
+ if (style === undefined && testID === undefined) return <>{children}</>;
85
+ return (
86
+ <View style={style} testID={testID}>
87
+ {children}
88
+ </View>
89
+ );
90
+ }
91
+
44
92
  /** The two platform pieces a fork supplies. */
45
93
  export interface LoadingPlatform {
46
94
  /** Forked: react-native-svg on native, a DOM `<svg>` on web. */
@@ -61,6 +109,7 @@ export function bindLoading({ SpinnerIcon, TopLoading }: LoadingPlatform) {
61
109
  showText = true,
62
110
  iconSize,
63
111
  spinnerIcon,
112
+ accessibilityLabel,
64
113
  testID,
65
114
  }) => {
66
115
  const theme = useTheme();
@@ -71,7 +120,7 @@ export function bindLoading({ SpinnerIcon, TopLoading }: LoadingPlatform) {
71
120
  const textColor = color ?? theme.colors.textSecondary;
72
121
 
73
122
  return (
74
- <View style={[styles.container, style]} testID={testID}>
123
+ <LoadingRoot accessibilityLabel={accessibilityLabel} style={[styles.container, style]} testID={testID}>
75
124
  {spinnerIcon ?? <SpinnerIcon size={effectiveIconSize} color={spinnerColor} className={className} />}
76
125
  {showText && text && (
77
126
  <Text
@@ -84,7 +133,7 @@ export function bindLoading({ SpinnerIcon, TopLoading }: LoadingPlatform) {
84
133
  {text}
85
134
  </Text>
86
135
  )}
87
- </View>
136
+ </LoadingRoot>
88
137
  );
89
138
  };
90
139
 
@@ -96,6 +145,7 @@ export function bindLoading({ SpinnerIcon, TopLoading }: LoadingPlatform) {
96
145
  style,
97
146
  textStyle,
98
147
  spinnerIcon,
148
+ accessibilityLabel,
99
149
  testID,
100
150
  }) => {
101
151
  const theme = useTheme();
@@ -105,7 +155,7 @@ export function bindLoading({ SpinnerIcon, TopLoading }: LoadingPlatform) {
105
155
  const textColor = theme.colors.textSecondary;
106
156
 
107
157
  return (
108
- <View style={[styles.inlineContainer, style]} testID={testID}>
158
+ <LoadingRoot accessibilityLabel={accessibilityLabel} style={[styles.inlineContainer, style]} testID={testID}>
109
159
  {spinnerIcon ?? <SpinnerIcon size={sizeConfig.spinner} color={spinnerColor} />}
110
160
  {text && (
111
161
  <Text
@@ -117,7 +167,7 @@ export function bindLoading({ SpinnerIcon, TopLoading }: LoadingPlatform) {
117
167
  {text}
118
168
  </Text>
119
169
  )}
120
- </View>
170
+ </LoadingRoot>
121
171
  );
122
172
  };
123
173
 
@@ -20,6 +20,17 @@ interface BaseLoadingProps {
20
20
  style?: ViewStyle;
21
21
  /** Whether loading is active (for animated variants) */
22
22
  showLoading?: boolean;
23
+ /**
24
+ * Names the indicator and makes it an indeterminate `progressbar` to
25
+ * assistive technology (`role="progressbar"` with `aria-label` and
26
+ * `aria-busy` on web; the same role, label and busy state natively). A
27
+ * spinner draws no words, so nothing else can say what is loading.
28
+ *
29
+ * Opt-in: without it the indicator carries no role, as before — which is
30
+ * right when it sits INSIDE a named progressbar already (an upload overlay),
31
+ * where a second one would announce the same wait twice.
32
+ */
33
+ accessibilityLabel?: string;
23
34
  testID?: string;
24
35
  }
25
36