@astryxdesign/core 0.1.4-canary.39ffd21 → 0.1.4-canary.3d64ef2

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 (234) hide show
  1. package/dist/Banner/Banner.d.ts.map +1 -1
  2. package/dist/Banner/Banner.js +8 -1
  3. package/dist/Calendar/Calendar.d.ts.map +1 -1
  4. package/dist/Calendar/Calendar.js +31 -11
  5. package/dist/Calendar/styles.d.ts +2 -2
  6. package/dist/Calendar/styles.d.ts.map +1 -1
  7. package/dist/Calendar/styles.js +2 -2
  8. package/dist/Card/Card.d.ts +4 -2
  9. package/dist/Card/Card.d.ts.map +1 -1
  10. package/dist/Card/Card.js +15 -7
  11. package/dist/Carousel/Carousel.d.ts.map +1 -1
  12. package/dist/Carousel/Carousel.js +1 -0
  13. package/dist/Chat/ChatToolCalls.d.ts.map +1 -1
  14. package/dist/Chat/ChatToolCalls.js +8 -1
  15. package/dist/CheckboxInput/CheckboxInput.d.ts +1 -1
  16. package/dist/CheckboxInput/CheckboxInput.d.ts.map +1 -1
  17. package/dist/CheckboxInput/CheckboxInput.js +3 -1
  18. package/dist/ClickableCard/ClickableCard.d.ts.map +1 -1
  19. package/dist/ClickableCard/ClickableCard.js +26 -3
  20. package/dist/CodeBlock/CodeBlock.d.ts +10 -1
  21. package/dist/CodeBlock/CodeBlock.d.ts.map +1 -1
  22. package/dist/CodeBlock/CodeBlock.js +23 -5
  23. package/dist/Collapsible/Collapsible.d.ts +9 -3
  24. package/dist/Collapsible/Collapsible.d.ts.map +1 -1
  25. package/dist/Collapsible/Collapsible.js +101 -13
  26. package/dist/Collapsible/CollapsibleGroup.d.ts +39 -7
  27. package/dist/Collapsible/CollapsibleGroup.d.ts.map +1 -1
  28. package/dist/Collapsible/CollapsibleGroup.js +70 -9
  29. package/dist/Collapsible/CollapsibleGroupContext.d.ts +31 -0
  30. package/dist/Collapsible/CollapsibleGroupContext.d.ts.map +1 -1
  31. package/dist/Collapsible/CollapsibleGroupContext.js +33 -3
  32. package/dist/Collapsible/index.d.ts +1 -0
  33. package/dist/Collapsible/index.d.ts.map +1 -1
  34. package/dist/ContextMenu/ContextMenu.d.ts.map +1 -1
  35. package/dist/ContextMenu/ContextMenu.js +2 -0
  36. package/dist/DateTimeInput/DateTimeInput.d.ts.map +1 -1
  37. package/dist/DateTimeInput/DateTimeInput.js +2 -0
  38. package/dist/Grid/Grid.d.ts +4 -3
  39. package/dist/Grid/Grid.d.ts.map +1 -1
  40. package/dist/Grid/Grid.js +26 -9
  41. package/dist/Icon/Icon.d.ts.map +1 -1
  42. package/dist/Icon/Icon.js +5 -1
  43. package/dist/InputGroup/InputGroup.js +2 -2
  44. package/dist/Item/Item.js +1 -1
  45. package/dist/Lightbox/Lightbox.d.ts.map +1 -1
  46. package/dist/Lightbox/Lightbox.js +26 -2
  47. package/dist/NumberInput/NumberInput.d.ts.map +1 -1
  48. package/dist/NumberInput/NumberInput.js +32 -16
  49. package/dist/Pagination/Pagination.d.ts.map +1 -1
  50. package/dist/Pagination/Pagination.js +87 -36
  51. package/dist/PowerSearch/PowerSearch.d.ts.map +1 -1
  52. package/dist/PowerSearch/PowerSearch.js +39 -21
  53. package/dist/ProgressBar/ProgressBar.d.ts.map +1 -1
  54. package/dist/ProgressBar/ProgressBar.js +2 -1
  55. package/dist/Resizable/ResizeHandle.d.ts.map +1 -1
  56. package/dist/Resizable/ResizeHandle.js +10 -2
  57. package/dist/SideNav/SideNav.d.ts.map +1 -1
  58. package/dist/SideNav/SideNav.js +6 -2
  59. package/dist/TabList/TabMenu.d.ts.map +1 -1
  60. package/dist/TabList/TabMenu.js +34 -7
  61. package/dist/Table/BaseTable.d.ts.map +1 -1
  62. package/dist/Table/BaseTable.js +14 -3
  63. package/dist/Table/plugins/columnResize/useTableColumnResize.d.ts.map +1 -1
  64. package/dist/Table/plugins/columnResize/useTableColumnResize.js +5 -2
  65. package/dist/Table/plugins/pagination/paginateData.d.ts.map +1 -1
  66. package/dist/Table/plugins/pagination/paginateData.js +5 -1
  67. package/dist/Table/plugins/selection/useTableSelectionState.d.ts.map +1 -1
  68. package/dist/Table/plugins/selection/useTableSelectionState.js +11 -1
  69. package/dist/Table/plugins/sortable/useTableSortableState.d.ts.map +1 -1
  70. package/dist/Table/plugins/sortable/useTableSortableState.js +8 -4
  71. package/dist/Thumbnail/Thumbnail.d.ts +3 -2
  72. package/dist/Thumbnail/Thumbnail.d.ts.map +1 -1
  73. package/dist/Thumbnail/Thumbnail.js +1 -0
  74. package/dist/TimeInput/TimeInput.d.ts.map +1 -1
  75. package/dist/TimeInput/TimeInput.js +6 -1
  76. package/dist/Toast/Toast.d.ts.map +1 -1
  77. package/dist/Toast/Toast.js +11 -3
  78. package/dist/Toast/ToastViewport.d.ts.map +1 -1
  79. package/dist/Toast/ToastViewport.js +12 -0
  80. package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
  81. package/dist/Typeahead/BaseTypeahead.js +5 -2
  82. package/dist/astryx.css +7 -0
  83. package/dist/astryx.umd.js +48 -48
  84. package/dist/astryx.umd.js.map +4 -4
  85. package/dist/hooks/focusableSelector.d.ts +1 -1
  86. package/dist/hooks/focusableSelector.d.ts.map +1 -1
  87. package/dist/hooks/focusableSelector.js +1 -1
  88. package/dist/hooks/useFocusTrap.d.ts +3 -0
  89. package/dist/hooks/useFocusTrap.d.ts.map +1 -1
  90. package/dist/hooks/useFocusTrap.js +38 -1
  91. package/dist/theme/defineTheme.d.ts +1 -1
  92. package/dist/theme/defineTheme.d.ts.map +1 -1
  93. package/dist/theme/expandColorScale.d.ts.map +1 -1
  94. package/dist/theme/expandColorScale.js +10 -3
  95. package/dist/theme/hct.d.ts +0 -15
  96. package/dist/theme/hct.d.ts.map +1 -1
  97. package/dist/theme/hct.js +9 -9
  98. package/dist/theme/tokens.d.ts +10 -2
  99. package/dist/theme/tokens.d.ts.map +1 -1
  100. package/dist/theme/tokens.js +220 -4
  101. package/dist/utils/color.d.ts +42 -0
  102. package/dist/utils/color.d.ts.map +1 -0
  103. package/dist/utils/color.js +150 -0
  104. package/dist/utils/index.d.ts +2 -0
  105. package/dist/utils/index.d.ts.map +1 -1
  106. package/dist/utils/index.js +2 -1
  107. package/dist/utils/timeParser.d.ts.map +1 -1
  108. package/dist/utils/timeParser.js +8 -5
  109. package/package.json +1 -1
  110. package/src/Avatar/Avatar.doc.mjs +11 -0
  111. package/src/Banner/Banner.doc.mjs +8 -0
  112. package/src/Banner/Banner.test.tsx +38 -0
  113. package/src/Banner/Banner.tsx +8 -1
  114. package/src/Calendar/Calendar.doc.mjs +1 -1
  115. package/src/Calendar/Calendar.test.tsx +112 -4
  116. package/src/Calendar/Calendar.tsx +26 -5
  117. package/src/Calendar/styles.ts +12 -2
  118. package/src/Card/Card.tsx +16 -6
  119. package/src/Carousel/Carousel.doc.mjs +12 -12
  120. package/src/Carousel/Carousel.test.tsx +13 -0
  121. package/src/Carousel/Carousel.tsx +1 -0
  122. package/src/Chat/Chat.doc.mjs +1 -1
  123. package/src/Chat/ChatToolCalls.test.tsx +55 -0
  124. package/src/Chat/ChatToolCalls.tsx +10 -12
  125. package/src/CheckboxInput/CheckboxInput.doc.mjs +3 -3
  126. package/src/CheckboxInput/CheckboxInput.test.tsx +71 -4
  127. package/src/CheckboxInput/CheckboxInput.tsx +2 -0
  128. package/src/Citation/Citation.doc.mjs +5 -0
  129. package/src/ClickableCard/ClickableCard.tsx +45 -2
  130. package/src/CodeBlock/CodeBlock.doc.mjs +7 -2
  131. package/src/CodeBlock/CodeBlock.test.tsx +87 -3
  132. package/src/CodeBlock/CodeBlock.tsx +43 -3
  133. package/src/Collapsible/Collapsible.doc.mjs +8 -4
  134. package/src/Collapsible/Collapsible.tsx +86 -10
  135. package/src/Collapsible/CollapsibleGroup.doc.mjs +27 -3
  136. package/src/Collapsible/CollapsibleGroup.test.tsx +288 -0
  137. package/src/Collapsible/CollapsibleGroup.tsx +107 -9
  138. package/src/Collapsible/CollapsibleGroupContext.tsx +42 -2
  139. package/src/Collapsible/index.ts +4 -0
  140. package/src/CommandPalette/CommandPaletteEmpty.doc.mjs +5 -0
  141. package/src/CommandPalette/CommandPaletteGroup.doc.mjs +10 -0
  142. package/src/CommandPalette/CommandPaletteInput.doc.mjs +6 -0
  143. package/src/CommandPalette/CommandPaletteItem.doc.mjs +6 -0
  144. package/src/CommandPalette/CommandPaletteList.doc.mjs +9 -0
  145. package/src/ContextMenu/ContextMenu.doc.mjs +15 -0
  146. package/src/ContextMenu/ContextMenu.test.tsx +15 -0
  147. package/src/ContextMenu/ContextMenu.tsx +2 -0
  148. package/src/ContextMenu/ContextMenuItem.doc.mjs +6 -0
  149. package/src/DateTimeInput/DateTimeInput.test.tsx +70 -0
  150. package/src/DateTimeInput/DateTimeInput.tsx +2 -0
  151. package/src/Dialog/DialogHeader.doc.mjs +45 -0
  152. package/src/DropdownMenu/DropdownMenu.doc.mjs +1 -1
  153. package/src/Field/FieldLabel.test.tsx +2 -2
  154. package/src/FormLayout/__snapshots__/FormLayout.test.tsx.snap +0 -3
  155. package/src/Grid/Grid.test.tsx +43 -18
  156. package/src/Grid/Grid.tsx +26 -9
  157. package/src/Icon/Icon.test.tsx +41 -29
  158. package/src/Icon/Icon.tsx +6 -2
  159. package/src/InputGroup/InputGroup.tsx +2 -2
  160. package/src/Item/Item.tsx +1 -1
  161. package/src/Layout/LayoutContent.doc.mjs +12 -0
  162. package/src/Layout/LayoutPanel.doc.mjs +40 -0
  163. package/src/Lightbox/Lightbox.doc.mjs +5 -0
  164. package/src/Lightbox/Lightbox.test.tsx +123 -2
  165. package/src/Lightbox/Lightbox.tsx +29 -1
  166. package/src/NavMenu/NavMenu.doc.mjs +15 -0
  167. package/src/NumberInput/NumberInput.test.tsx +48 -3
  168. package/src/NumberInput/NumberInput.tsx +32 -15
  169. package/src/Overlay/Overlay.doc.mjs +6 -0
  170. package/src/Pagination/Pagination.test.tsx +143 -0
  171. package/src/Pagination/Pagination.tsx +75 -23
  172. package/src/Popover/Popover.test.tsx +65 -0
  173. package/src/PowerSearch/PowerSearch.doc.mjs +2 -2
  174. package/src/PowerSearch/PowerSearch.test.tsx +109 -1
  175. package/src/PowerSearch/PowerSearch.tsx +37 -16
  176. package/src/ProgressBar/ProgressBar.test.tsx +10 -0
  177. package/src/ProgressBar/ProgressBar.tsx +2 -1
  178. package/src/Resizable/ResizeHandle.test.tsx +180 -0
  179. package/src/Resizable/ResizeHandle.tsx +10 -2
  180. package/src/SegmentedControl/SegmentedControl.doc.mjs +1 -1
  181. package/src/SelectableCard/SelectableCard.doc.mjs +1 -1
  182. package/src/SideNav/SideNav.doc.mjs +7 -1
  183. package/src/SideNav/SideNav.test.tsx +20 -0
  184. package/src/SideNav/SideNav.tsx +6 -2
  185. package/src/TabList/TabList.test.tsx +185 -1
  186. package/src/TabList/TabMenu.tsx +45 -9
  187. package/src/Table/BaseTable.tsx +13 -2
  188. package/src/Table/Table.test.tsx +51 -0
  189. package/src/Table/plugins/columnResize/useTableColumnResize.test.tsx +13 -0
  190. package/src/Table/plugins/columnResize/useTableColumnResize.tsx +3 -1
  191. package/src/Table/plugins/pagination/paginateData.ts +5 -1
  192. package/src/Table/plugins/pagination/useTablePagination.test.tsx +13 -0
  193. package/src/Table/plugins/selection/useTableSelectionState.test.tsx +23 -0
  194. package/src/Table/plugins/selection/useTableSelectionState.tsx +11 -6
  195. package/src/Table/plugins/sortable/useTableSortableState.test.tsx +35 -1
  196. package/src/Table/plugins/sortable/useTableSortableState.tsx +8 -4
  197. package/src/TextArea/TextArea.test.tsx +3 -3
  198. package/src/TextInput/TextInput.test.tsx +3 -3
  199. package/src/Thumbnail/Thumbnail.test.tsx +36 -2
  200. package/src/Thumbnail/Thumbnail.tsx +4 -2
  201. package/src/TimeInput/TimeInput.test.tsx +37 -0
  202. package/src/TimeInput/TimeInput.tsx +11 -1
  203. package/src/Timestamp/Timestamp.doc.mjs +1 -1
  204. package/src/Toast/Toast.tsx +11 -3
  205. package/src/Toast/ToastViewport.test.tsx +64 -0
  206. package/src/Toast/ToastViewport.tsx +12 -0
  207. package/src/ToggleButton/ToggleButton.doc.mjs +1 -0
  208. package/src/Tokenizer/Tokenizer.doc.mjs +2 -2
  209. package/src/TopNav/TopNav.doc.mjs +1 -1
  210. package/src/TreeList/TreeList.doc.mjs +2 -2
  211. package/src/Typeahead/BaseTypeahead.doc.mjs +19 -0
  212. package/src/Typeahead/BaseTypeahead.tsx +5 -2
  213. package/src/Typeahead/Typeahead.doc.mjs +1 -1
  214. package/src/Typeahead/Typeahead.test.tsx +45 -1
  215. package/src/__tests__/TestIcon.tsx +26 -0
  216. package/src/docPropReferences.test.ts +178 -0
  217. package/src/hooks/focusableSelector.ts +1 -1
  218. package/src/hooks/useFocusTrap.doc.mjs +4 -2
  219. package/src/hooks/useFocusTrap.test.tsx +149 -1
  220. package/src/hooks/useFocusTrap.ts +50 -1
  221. package/src/theme/SyntaxTheme.doc.mjs +4 -4
  222. package/src/theme/defineTheme.ts +4 -13
  223. package/src/theme/derivedVarRegistry.test.ts +2 -2
  224. package/src/theme/expandColorScale.test.ts +29 -0
  225. package/src/theme/expandColorScale.ts +11 -6
  226. package/src/theme/hct.ts +9 -17
  227. package/src/theme/tokens.test.ts +182 -9
  228. package/src/theme/tokens.ts +239 -4
  229. package/src/theme/useTheme.test.tsx +20 -0
  230. package/src/utils/color.test.ts +133 -0
  231. package/src/utils/color.ts +148 -0
  232. package/src/utils/index.ts +3 -0
  233. package/src/utils/timeParser.test.ts +13 -0
  234. package/src/utils/timeParser.ts +8 -5
@@ -0,0 +1,150 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file color.ts
5
+ * @input CSS color strings (hex, rgb()/rgba(), a small set of named colors)
6
+ * @output Shared color parsing/formatting primitives used across the design system
7
+ * @position Package utility; backs theme token resolution, HCT color math, and chart rendering
8
+ *
9
+ * A single home for the color parsers that were previously duplicated across
10
+ * the theme layer, charts, and lab. Consumers get one well-tested definition of
11
+ * "parse this color" rather than several subtly different regexes.
12
+ */
13
+
14
+ /** A color decomposed into 0-255 RGB channels and a 0-1 alpha. */
15
+
16
+ /** The named colors the design system relies on in token expressions. */
17
+ const NAMED_COLORS = {
18
+ transparent: {
19
+ r: 0,
20
+ g: 0,
21
+ b: 0,
22
+ a: 0
23
+ },
24
+ black: {
25
+ r: 0,
26
+ g: 0,
27
+ b: 0,
28
+ a: 1
29
+ },
30
+ white: {
31
+ r: 255,
32
+ g: 255,
33
+ b: 255,
34
+ a: 1
35
+ }
36
+ };
37
+ const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
38
+
39
+ /** Expand a shorthand hex body (`rgb`/`rgba`) to its full form (`rrggbb`/`rrggbbaa`). */
40
+ function expandShorthand(body) {
41
+ return body.split('').map(c => c + c).join('');
42
+ }
43
+
44
+ /**
45
+ * Parse a hex color into {@link RGBA}. Accepts `#rgb`, `#rgba`, `#rrggbb`, and
46
+ * `#rrggbbaa`, with or without the leading `#`. Returns null for anything else.
47
+ */
48
+ export function parseHex(hex) {
49
+ if (typeof hex !== 'string') {
50
+ return null;
51
+ }
52
+ const body = hex.trim().replace(/^#/, '');
53
+ const normalized = body.length === 3 || body.length === 4 ? expandShorthand(body) : body;
54
+ if (normalized.length !== 6 && normalized.length !== 8 || !/^[0-9a-fA-F]+$/.test(normalized)) {
55
+ return null;
56
+ }
57
+ return {
58
+ r: parseInt(normalized.slice(0, 2), 16),
59
+ g: parseInt(normalized.slice(2, 4), 16),
60
+ b: parseInt(normalized.slice(4, 6), 16),
61
+ a: normalized.length === 8 ? parseInt(normalized.slice(6, 8), 16) / 255 : 1
62
+ };
63
+ }
64
+
65
+ /**
66
+ * Parse an `rgb()`/`rgba()` color into {@link RGBA}. Accepts comma- or
67
+ * space-separated channels, an optional `/ alpha`, and percentage channels.
68
+ */
69
+ export function parseRgb(value) {
70
+ const open = value.indexOf('(');
71
+ if (open === -1 || !value.trim().endsWith(')')) {
72
+ return null;
73
+ }
74
+ const body = value.slice(open + 1, value.lastIndexOf(')')).replace(/\//g, ' ');
75
+ const parts = body.split(/[\s,]+/).map(p => p.trim()).filter(Boolean);
76
+ if (parts.length < 3) {
77
+ return null;
78
+ }
79
+ const channel = p => {
80
+ const n = p.endsWith('%') ? parseFloat(p) / 100 * 255 : parseFloat(p);
81
+ return clamp(n, 0, 255);
82
+ };
83
+ const r = channel(parts[0]);
84
+ const g = channel(parts[1]);
85
+ const b = channel(parts[2]);
86
+ if ([r, g, b].some(Number.isNaN)) {
87
+ return null;
88
+ }
89
+ let a = 1;
90
+ if (parts.length >= 4) {
91
+ const raw = parts[3];
92
+ a = raw.endsWith('%') ? parseFloat(raw) / 100 : parseFloat(raw);
93
+ if (Number.isNaN(a)) {
94
+ return null;
95
+ }
96
+ a = clamp(a, 0, 1);
97
+ }
98
+ return {
99
+ r,
100
+ g,
101
+ b,
102
+ a
103
+ };
104
+ }
105
+
106
+ /**
107
+ * Parse a concrete CSS color string into {@link RGBA}. Supports hex,
108
+ * `rgb()`/`rgba()`, and the named colors used in token expressions. Returns
109
+ * null for anything it can't evaluate (e.g. `var()`, `oklch()`, unknown names)
110
+ * so callers can preserve the original expression rather than guessing.
111
+ */
112
+ export function parseColor(value) {
113
+ const trimmed = value.trim();
114
+ const named = NAMED_COLORS[trimmed.toLowerCase()];
115
+ if (named) {
116
+ return {
117
+ ...named
118
+ };
119
+ }
120
+ if (trimmed.startsWith('#')) {
121
+ return parseHex(trimmed);
122
+ }
123
+ if (/^rgba?\(/i.test(trimmed)) {
124
+ return parseRgb(trimmed);
125
+ }
126
+ return null;
127
+ }
128
+
129
+ /** Format RGB channels (0-255) as an uppercase `#RRGGBB` string. */
130
+ export function formatHex(r, g, b) {
131
+ const channel = c => clamp(Math.round(c), 0, 255).toString(16).padStart(2, '0').toUpperCase();
132
+ return `#${channel(r)}${channel(g)}${channel(b)}`;
133
+ }
134
+
135
+ /**
136
+ * Serialize {@link RGBA} to a compact, JS-usable CSS color: `#RRGGBB` when
137
+ * fully opaque, otherwise `rgba(r, g, b, a)`.
138
+ */
139
+ export function formatColor({
140
+ r,
141
+ g,
142
+ b,
143
+ a
144
+ }) {
145
+ if (a >= 1) {
146
+ return formatHex(r, g, b);
147
+ }
148
+ const round = n => clamp(Math.round(n), 0, 255);
149
+ return `rgba(${round(r)}, ${round(g)}, ${round(b)}, ${parseFloat(a.toFixed(4))})`;
150
+ }
@@ -22,4 +22,6 @@ export { observeResize, unobserveResize } from './sharedResizeObserver';
22
22
  export { isRenderable } from './isRenderable';
23
23
  export { getInputARIA } from './inputAria';
24
24
  export type { InputARIA, InputARIAInputGroup } from './inputAria';
25
+ export { parseHex, parseRgb, parseColor, formatHex, formatColor } from './color';
26
+ export type { RGBA } from './color';
25
27
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/utils/index.ts"],"names":[],"mappings":"AAEA;;;;;GAKG;AAEH,YAAY,EAAC,SAAS,EAAC,MAAM,SAAS,CAAC;AAEvC,YAAY,EAAC,aAAa,EAAE,SAAS,EAAE,SAAS,EAAE,SAAS,EAAC,MAAM,aAAa,CAAC;AAEhF,OAAO,EACL,cAAc,EACd,SAAS,EACT,QAAQ,EACR,gBAAgB,GACjB,MAAM,cAAc,CAAC;AAEtB,OAAO,EACL,eAAe,EACf,gBAAgB,EAChB,cAAc,EACd,eAAe,EACf,iBAAiB,EACjB,cAAc,EACd,cAAc,EACd,kBAAkB,EAClB,kBAAkB,EAClB,gBAAgB,EAChB,kBAAkB,EAClB,oBAAoB,EACpB,iBAAiB,EACjB,gBAAgB,EAChB,gBAAgB,EAChB,YAAY,EACZ,YAAY,EACZ,kBAAkB,EAClB,wBAAwB,EACxB,uBAAuB,EACvB,8BAA8B,EAC9B,sBAAsB,EACtB,eAAe,EACf,wBAAwB,EACxB,gBAAgB,EAChB,sBAAsB,EACtB,iBAAiB,EACjB,2BAA2B,GAC5B,MAAM,aAAa,CAAC;AAErB,OAAO,EACL,YAAY,EACZ,aAAa,EACb,oBAAoB,EACpB,oBAAoB,EACpB,cAAc,EACd,WAAW,EACX,aAAa,EACb,SAAS,EACT,UAAU,EACV,mBAAmB,GACpB,MAAM,cAAc,CAAC;AACtB,YAAY,EAAC,aAAa,EAAE,UAAU,EAAC,MAAM,cAAc,CAAC;AAE5D,OAAO,EAAC,aAAa,EAAC,MAAM,iBAAiB,CAAC;AAC9C,OAAO,EAAC,MAAM,EAAE,KAAK,GAAG,EAAE,KAAK,WAAW,EAAC,MAAM,UAAU,CAAC;AAE5D,OAAO,EAAC,UAAU,EAAC,MAAM,cAAc,CAAC;AACxC,OAAO,EAAC,SAAS,EAAC,MAAM,aAAa,CAAC;AACtC,OAAO,EAAC,UAAU,EAAE,mBAAmB,EAAC,MAAM,cAAc,CAAC;AAC7D,YAAY,EACV,UAAU,EACV,UAAU,EACV,mBAAmB,EACnB,UAAU,GACX,MAAM,cAAc,CAAC;AACtB,OAAO,EAAC,UAAU,EAAE,YAAY,EAAC,MAAM,cAAc,CAAC;AACtD,YAAY,EAAC,SAAS,EAAC,MAAM,cAAc,CAAC;AAC5C,OAAO,EAAC,aAAa,EAAE,eAAe,EAAC,MAAM,wBAAwB,CAAC;AACtE,OAAO,EAAC,YAAY,EAAC,MAAM,gBAAgB,CAAC;AAC5C,OAAO,EAAC,YAAY,EAAC,MAAM,aAAa,CAAC;AACzC,YAAY,EAAC,SAAS,EAAE,mBAAmB,EAAC,MAAM,aAAa,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/utils/index.ts"],"names":[],"mappings":"AAEA;;;;;GAKG;AAEH,YAAY,EAAC,SAAS,EAAC,MAAM,SAAS,CAAC;AAEvC,YAAY,EAAC,aAAa,EAAE,SAAS,EAAE,SAAS,EAAE,SAAS,EAAC,MAAM,aAAa,CAAC;AAEhF,OAAO,EACL,cAAc,EACd,SAAS,EACT,QAAQ,EACR,gBAAgB,GACjB,MAAM,cAAc,CAAC;AAEtB,OAAO,EACL,eAAe,EACf,gBAAgB,EAChB,cAAc,EACd,eAAe,EACf,iBAAiB,EACjB,cAAc,EACd,cAAc,EACd,kBAAkB,EAClB,kBAAkB,EAClB,gBAAgB,EAChB,kBAAkB,EAClB,oBAAoB,EACpB,iBAAiB,EACjB,gBAAgB,EAChB,gBAAgB,EAChB,YAAY,EACZ,YAAY,EACZ,kBAAkB,EAClB,wBAAwB,EACxB,uBAAuB,EACvB,8BAA8B,EAC9B,sBAAsB,EACtB,eAAe,EACf,wBAAwB,EACxB,gBAAgB,EAChB,sBAAsB,EACtB,iBAAiB,EACjB,2BAA2B,GAC5B,MAAM,aAAa,CAAC;AAErB,OAAO,EACL,YAAY,EACZ,aAAa,EACb,oBAAoB,EACpB,oBAAoB,EACpB,cAAc,EACd,WAAW,EACX,aAAa,EACb,SAAS,EACT,UAAU,EACV,mBAAmB,GACpB,MAAM,cAAc,CAAC;AACtB,YAAY,EAAC,aAAa,EAAE,UAAU,EAAC,MAAM,cAAc,CAAC;AAE5D,OAAO,EAAC,aAAa,EAAC,MAAM,iBAAiB,CAAC;AAC9C,OAAO,EAAC,MAAM,EAAE,KAAK,GAAG,EAAE,KAAK,WAAW,EAAC,MAAM,UAAU,CAAC;AAE5D,OAAO,EAAC,UAAU,EAAC,MAAM,cAAc,CAAC;AACxC,OAAO,EAAC,SAAS,EAAC,MAAM,aAAa,CAAC;AACtC,OAAO,EAAC,UAAU,EAAE,mBAAmB,EAAC,MAAM,cAAc,CAAC;AAC7D,YAAY,EACV,UAAU,EACV,UAAU,EACV,mBAAmB,EACnB,UAAU,GACX,MAAM,cAAc,CAAC;AACtB,OAAO,EAAC,UAAU,EAAE,YAAY,EAAC,MAAM,cAAc,CAAC;AACtD,YAAY,EAAC,SAAS,EAAC,MAAM,cAAc,CAAC;AAC5C,OAAO,EAAC,aAAa,EAAE,eAAe,EAAC,MAAM,wBAAwB,CAAC;AACtE,OAAO,EAAC,YAAY,EAAC,MAAM,gBAAgB,CAAC;AAC5C,OAAO,EAAC,YAAY,EAAC,MAAM,aAAa,CAAC;AACzC,YAAY,EAAC,SAAS,EAAE,mBAAmB,EAAC,MAAM,aAAa,CAAC;AAEhE,OAAO,EAAC,QAAQ,EAAE,QAAQ,EAAE,UAAU,EAAE,SAAS,EAAE,WAAW,EAAC,MAAM,SAAS,CAAC;AAC/E,YAAY,EAAC,IAAI,EAAC,MAAM,SAAS,CAAC"}
@@ -18,4 +18,5 @@ export { themeProps, themeDataAttributes } from "./themeProps.js";
18
18
  export { groupItems, getItemGroup } from "./groupItems.js";
19
19
  export { observeResize, unobserveResize } from "./sharedResizeObserver.js";
20
20
  export { isRenderable } from "./isRenderable.js";
21
- export { getInputARIA } from "./inputAria.js";
21
+ export { getInputARIA } from "./inputAria.js";
22
+ export { parseHex, parseRgb, parseColor, formatHex, formatColor } from "./color.js";
@@ -1 +1 @@
1
- {"version":3,"file":"timeParser.d.ts","sourceRoot":"","sources":["../../src/utils/timeParser.ts"],"names":[],"mappings":"AAEA;;;;;;;GAOG;AAEH;;;GAGG;AACH,MAAM,MAAM,aAAa,GAAG,MAAM,GAAG;IAAC,QAAQ,CAAC,OAAO,EAAE,eAAe,CAAA;CAAC,CAAC;AAEzE;;GAEG;AACH,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;CAChB;AAED;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,MAAM,GAAG,aAAa,GAAG,IAAI,CAMvE;AAED;;;GAGG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,UAAU,GAAG,IAAI,CA6B5D;AAED;;GAEG;AACH,wBAAgB,aAAa,CAC3B,IAAI,EAAE,UAAU,EAChB,cAAc,GAAE,OAAe,GAC9B,aAAa,CAUf;AAED;;;GAGG;AACH,wBAAgB,oBAAoB,CAClC,IAAI,EAAE,aAAa,GAAG,SAAS,EAC/B,cAAc,GAAE,OAAe,GAC9B,MAAM,CAqBR;AAED;;;GAGG;AACH,wBAAgB,oBAAoB,CAClC,IAAI,EAAE,aAAa,GAAG,SAAS,EAC/B,cAAc,GAAE,OAAe,GAC9B,MAAM,CAmBR;AAED;;;;;;GAMG;AACH,wBAAgB,cAAc,CAC5B,KAAK,EAAE,MAAM,EACb,cAAc,GAAE,OAAe,GAC9B,aAAa,GAAG,IAAI,CAuGtB;AAED;;;;;GAKG;AACH,wBAAgB,WAAW,CACzB,CAAC,EAAE,aAAa,GAAG,SAAS,EAC5B,CAAC,EAAE,aAAa,GAAG,SAAS,GAC3B,MAAM,CA4BR;AAED;;GAEG;AACH,wBAAgB,aAAa,CAC3B,IAAI,EAAE,aAAa,EACnB,GAAG,CAAC,EAAE,aAAa,EACnB,GAAG,CAAC,EAAE,aAAa,GAClB,OAAO,CAQT;AAED;;GAEG;AACH,wBAAgB,SAAS,CACvB,IAAI,EAAE,aAAa,EACnB,GAAG,CAAC,EAAE,aAAa,EACnB,GAAG,CAAC,EAAE,aAAa,EACnB,cAAc,GAAE,OAAe,GAC9B,aAAa,CAoBf;AAED;;GAEG;AACH,wBAAgB,UAAU,CACxB,IAAI,EAAE,aAAa,EACnB,YAAY,EAAE,MAAM,EACpB,cAAc,GAAE,OAAe,GAC9B,aAAa,CAqBf"}
1
+ {"version":3,"file":"timeParser.d.ts","sourceRoot":"","sources":["../../src/utils/timeParser.ts"],"names":[],"mappings":"AAEA;;;;;;;GAOG;AAEH;;;GAGG;AACH,MAAM,MAAM,aAAa,GAAG,MAAM,GAAG;IAAC,QAAQ,CAAC,OAAO,EAAE,eAAe,CAAA;CAAC,CAAC;AAEzE;;GAEG;AACH,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;CAChB;AAED;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,MAAM,GAAG,aAAa,GAAG,IAAI,CAMvE;AAED;;;GAGG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,UAAU,GAAG,IAAI,CA6B5D;AAED;;GAEG;AACH,wBAAgB,aAAa,CAC3B,IAAI,EAAE,UAAU,EAChB,cAAc,GAAE,OAAe,GAC9B,aAAa,CAUf;AAED;;;GAGG;AACH,wBAAgB,oBAAoB,CAClC,IAAI,EAAE,aAAa,GAAG,SAAS,EAC/B,cAAc,GAAE,OAAe,GAC9B,MAAM,CAqBR;AAED;;;GAGG;AACH,wBAAgB,oBAAoB,CAClC,IAAI,EAAE,aAAa,GAAG,SAAS,EAC/B,cAAc,GAAE,OAAe,GAC9B,MAAM,CAmBR;AAED;;;;;;GAMG;AACH,wBAAgB,cAAc,CAC5B,KAAK,EAAE,MAAM,EACb,cAAc,GAAE,OAAe,GAC9B,aAAa,GAAG,IAAI,CAuGtB;AAED;;;;;GAKG;AACH,wBAAgB,WAAW,CACzB,CAAC,EAAE,aAAa,GAAG,SAAS,EAC5B,CAAC,EAAE,aAAa,GAAG,SAAS,GAC3B,MAAM,CA4BR;AAED;;GAEG;AACH,wBAAgB,aAAa,CAC3B,IAAI,EAAE,aAAa,EACnB,GAAG,CAAC,EAAE,aAAa,EACnB,GAAG,CAAC,EAAE,aAAa,GAClB,OAAO,CAQT;AAED;;GAEG;AACH,wBAAgB,SAAS,CACvB,IAAI,EAAE,aAAa,EACnB,GAAG,CAAC,EAAE,aAAa,EACnB,GAAG,CAAC,EAAE,aAAa,EACnB,cAAc,GAAE,OAAe,GAC9B,aAAa,CAoBf;AAED;;GAEG;AACH,wBAAgB,UAAU,CACxB,IAAI,EAAE,aAAa,EACnB,YAAY,EAAE,MAAM,EACpB,cAAc,GAAE,OAAe,GAC9B,aAAa,CAwBf"}
@@ -305,13 +305,16 @@ export function adjustTime(time, deltaMinutes, includeSeconds = false) {
305
305
  if (!parsed) {
306
306
  return time;
307
307
  }
308
- let totalMinutes = parsed.hour * 60 + parsed.minute + deltaMinutes;
309
308
 
310
- // Wrap around midnight
311
- while (totalMinutes < 0) {
312
- totalMinutes += 24 * 60;
309
+ // A non-finite delta would spin the wrap-around forever (-Infinity) or
310
+ // produce "NaN:NaN" (NaN) — return the input unchanged instead.
311
+ if (!Number.isFinite(deltaMinutes)) {
312
+ return time;
313
313
  }
314
- totalMinutes = totalMinutes % (24 * 60);
314
+ let totalMinutes = parsed.hour * 60 + parsed.minute + deltaMinutes;
315
+
316
+ // Wrap around midnight (double-modulo handles negatives in O(1))
317
+ totalMinutes = (totalMinutes % (24 * 60) + 24 * 60) % (24 * 60);
315
318
  const newHour = Math.floor(totalMinutes / 60);
316
319
  const newMinute = totalMinutes % 60;
317
320
  return formatISOTime({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astryxdesign/core",
3
- "version": "0.1.4-canary.39ffd21",
3
+ "version": "0.1.4-canary.3d64ef2",
4
4
  "displayName": "XDS Core",
5
5
  "description": "The component library. Accessible, themeable React components with built-in spacing, dark mode, and StyleX styling.",
6
6
  "author": "Meta Open Source",
@@ -106,4 +106,15 @@ export const docsDense = {
106
106
  {guidance: false, description: 'Force a square or custom shape. Avatars are always circular.'},
107
107
  ],
108
108
  },
109
+ propDescriptions: {
110
+ src: 'primary image source URL',
111
+ fallbackSrc: 'fallback image when primary fails',
112
+ name: 'user name for initials and alt text',
113
+ alt: 'alt text; falls back to name',
114
+ size: "avatar size. Named ('tiny', 'xsmall', 'small', 'medium', 'large') or numeric px. Short names 'sm'/'md'/'lg' are NOT valid; use full words.",
115
+ status: 'corner content for status indicators',
116
+ },
117
+ components: [
118
+ {name: 'AvatarStatusDot', description: 'size-aware status indicator rendered in the Avatar corner'},
119
+ ],
109
120
  };
@@ -113,6 +113,7 @@ export const docs = {
113
113
  targets: [
114
114
  {className: 'astryx-banner', visualProps: ['container', 'status']},
115
115
  {className: 'astryx-banner-icon', visualProps: ['status']},
116
+ {className: 'astryx-banner-content', visualProps: ['container', 'status']},
116
117
  ],
117
118
  vars: [
118
119
  {name: '--_banner-radius', description: 'Border radius (card container only)', default: 'var(--radius-container)', private: true},
@@ -180,6 +181,13 @@ export const docsZh = {
180
181
  'status',
181
182
  ],
182
183
  },
184
+ {
185
+ className: 'astryx-banner-content',
186
+ visualProps: [
187
+ 'container',
188
+ 'status',
189
+ ],
190
+ },
183
191
  ],
184
192
  vars: [
185
193
  {name: '--_banner-radius', description: 'Border radius (card container only)', default: 'var(--radius-container)', private: true},
@@ -270,6 +270,44 @@ describe('Banner', () => {
270
270
  expect(expandIndex).toBeLessThan(dismissIndex);
271
271
  });
272
272
 
273
+ it('links the expand toggle to its content region via aria-controls', () => {
274
+ render(
275
+ <Banner status="info" title="Controls Test" defaultIsExpanded>
276
+ <div data-testid="region-content">Region content</div>
277
+ </Banner>,
278
+ );
279
+
280
+ const toggle = screen.getByRole('button', {name: 'Collapse'});
281
+ const controlsId = toggle.getAttribute('aria-controls');
282
+ // aria-controls must be present and point at the real content region.
283
+ expect(controlsId).toBeTruthy();
284
+ const region = document.getElementById(controlsId as string);
285
+ expect(region).not.toBeNull();
286
+ expect(region).toContainElement(screen.getByTestId('region-content'));
287
+ });
288
+
289
+ it('sets aria-controls only while the content region is mounted', async () => {
290
+ const user = userEvent.setup();
291
+ render(
292
+ <Banner status="info" title="Controls Toggle">
293
+ <div data-testid="region-content">Region content</div>
294
+ </Banner>,
295
+ );
296
+
297
+ // Collapsed: the region is unmounted, so no dangling aria-controls target.
298
+ const collapsedToggle = screen.getByRole('button', {name: 'Expand'});
299
+ expect(collapsedToggle).not.toHaveAttribute('aria-controls');
300
+
301
+ // Expanded: aria-controls resolves to the mounted region with the children.
302
+ await user.click(collapsedToggle);
303
+ const expandedToggle = screen.getByRole('button', {name: 'Collapse'});
304
+ const controlsId = expandedToggle.getAttribute('aria-controls');
305
+ expect(controlsId).toBeTruthy();
306
+ const region = document.getElementById(controlsId as string);
307
+ expect(region).not.toBeNull();
308
+ expect(region).toContainElement(screen.getByTestId('region-content'));
309
+ });
310
+
273
311
  it('does not render content area when no children', () => {
274
312
  const {container} = render(<Banner status="info" title="No Children" />);
275
313
  const root = container.firstElementChild;
@@ -31,7 +31,7 @@
31
31
  * - /packages/cli/templates/blocks/components/Banner/ (showcase blocks)
32
32
  */
33
33
 
34
- import {useState, type ReactNode} from 'react';
34
+ import {useId, useState, type ReactNode} from 'react';
35
35
  import * as stylex from '@stylexjs/stylex';
36
36
  import type {BaseProps} from '../BaseProps';
37
37
  import {Button} from '../Button';
@@ -387,6 +387,11 @@ export function Banner({
387
387
  }: BannerProps) {
388
388
  const [isDismissed, setIsDismissed] = useState(false);
389
389
  const [isExpanded, setIsExpanded] = useState(defaultIsExpanded);
390
+ // Links the expand/collapse toggle to the content region it shows/hides so
391
+ // assistive tech can move from the button to its controlled content
392
+ // (disclosure pattern). The region is conditionally rendered, so aria-controls
393
+ // below is set only while it's mounted to avoid a dangling reference.
394
+ const contentId = useId();
390
395
  const defaultIconName = defaultIconNames[status];
391
396
  const role = statusRole[status];
392
397
  const iconColor = statusIconColor[status];
@@ -477,6 +482,7 @@ export function Banner({
477
482
  }
478
483
  onClick={handleToggleExpand}
479
484
  aria-expanded={isExpanded}
485
+ aria-controls={showContent ? contentId : undefined}
480
486
  isIconOnly
481
487
  />
482
488
  )}
@@ -497,6 +503,7 @@ export function Banner({
497
503
  {/* Content area: collapsible card background — theme target ('banner-content') */}
498
504
  {showContent && (
499
505
  <div
506
+ id={contentId}
500
507
  {...mergeProps(
501
508
  themeProps('banner-content', {container, status}),
502
509
  stylex.props(styles.contentArea, isCard && styles.contentAreaCard),
@@ -19,7 +19,7 @@ export const docs = {
19
19
  {guidance: false, description: 'Disable large blocks of dates without context. The user should understand why dates are unavailable.'},
20
20
  ],
21
21
  anatomy: [
22
- {name: 'Month header', required: true, description: 'The month name and year with navigation arrows to move between months.'},
22
+ {name: 'Month header', required: true, description: 'The month name and year with navigation arrows to move between months. The arrows mirror automatically under dir="rtl".'},
23
23
  {name: 'Day grid', required: true, description: 'A 7-column grid of days with column headers for the day names.'},
24
24
  {name: 'Selected day', required: false, description: 'The currently selected date, highlighted. In range mode, the start and end dates plus the days between them.'},
25
25
  {name: 'Today marker', required: false, description: 'A subtle indicator on the current date for orientation.'},
@@ -12,8 +12,10 @@
12
12
  import {describe, it, expect, vi} from 'vitest';
13
13
  import {act, render, screen, within} from '@testing-library/react';
14
14
  import userEvent from '@testing-library/user-event';
15
+ import * as stylex from '@stylexjs/stylex';
15
16
  import {Calendar} from './Calendar';
16
17
  import type {CalendarHandle} from './Calendar';
18
+ import {calendarStyles} from './styles';
17
19
 
18
20
  /**
19
21
  * Helper to find a day button by its day number.
@@ -71,6 +73,24 @@ describe('Calendar', () => {
71
73
  expect(screen.getByText(expectedLabel)).toBeInTheDocument();
72
74
  });
73
75
 
76
+ it("marks today's cell with aria-current='date'", () => {
77
+ render(<Calendar />);
78
+
79
+ const now = new Date();
80
+ const iso = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
81
+
82
+ const todayCell = document.querySelector(`button[data-date="${iso}"]`);
83
+ expect(todayCell).not.toBeNull();
84
+ expect(todayCell).toHaveAttribute('aria-current', 'date');
85
+
86
+ // Only today's cell is marked current.
87
+ const others = Array.from(
88
+ document.querySelectorAll('button[data-date]'),
89
+ ).filter(el => el.getAttribute('data-date') !== iso);
90
+ expect(others.length).toBeGreaterThan(0);
91
+ others.forEach(el => expect(el).not.toHaveAttribute('aria-current'));
92
+ });
93
+
74
94
  it('displays day names', () => {
75
95
  render(<Calendar />);
76
96
 
@@ -97,7 +117,11 @@ describe('Calendar', () => {
97
117
  render(<Calendar value="2026-01-15" focusDate="2026-01-01" />);
98
118
 
99
119
  const day15 = getDayButton(15);
100
- expect(day15).toHaveAttribute('aria-selected', 'true');
120
+ // In an ARIA grid, selection state lives on the gridcell, not the button
121
+ // (a plain button role does not permit aria-selected).
122
+ const gridcell15 = day15.closest('[role="gridcell"]');
123
+ expect(gridcell15).toHaveAttribute('aria-selected', 'true');
124
+ expect(day15).not.toHaveAttribute('aria-selected');
101
125
  });
102
126
 
103
127
  it('calls onChange when date is selected', async () => {
@@ -215,6 +239,33 @@ describe('Calendar', () => {
215
239
  expect(screen.getByText(/February 2026.*March 2026/)).toBeInTheDocument();
216
240
  });
217
241
 
242
+ it('clamps out-of-range numberOfMonths to a single month', () => {
243
+ // 1000 would otherwise try to render 1000 month grids and lock the page up.
244
+ render(
245
+ <Calendar
246
+ numberOfMonths={1000 as unknown as 1 | 2}
247
+ focusDate="2026-01-01"
248
+ />,
249
+ );
250
+
251
+ // Only the focused month renders — no second month, no range separator.
252
+ expect(screen.getByText('January 2026')).toBeInTheDocument();
253
+ expect(screen.queryByText(/February 2026/)).not.toBeInTheDocument();
254
+ });
255
+
256
+ it('clamps numberOfMonths={0} to a single month', () => {
257
+ render(
258
+ <Calendar
259
+ numberOfMonths={0 as unknown as 1 | 2}
260
+ focusDate="2026-01-01"
261
+ />,
262
+ );
263
+
264
+ // 0 previously rendered no months at all; now falls back to one.
265
+ expect(screen.getByText('January 2026')).toBeInTheDocument();
266
+ expect(screen.getAllByRole('gridcell').length).toBeGreaterThan(0);
267
+ });
268
+
218
269
  // ─── Display Options ─────────────────────────────────────────
219
270
 
220
271
  it('shows week numbers when hasWeekNumbers is true', () => {
@@ -310,9 +361,22 @@ describe('Calendar', () => {
310
361
  const day12 = getDayButton(12);
311
362
  const day15 = getDayButton(15);
312
363
 
313
- expect(day10).toHaveAttribute('aria-selected', 'true');
314
- expect(day12).toHaveAttribute('aria-selected', 'true');
315
- expect(day15).toHaveAttribute('aria-selected', 'true');
364
+ // Selection state lives on the gridcell wrapper, not the day button.
365
+ expect(day10.closest('[role="gridcell"]')).toHaveAttribute(
366
+ 'aria-selected',
367
+ 'true',
368
+ );
369
+ expect(day12.closest('[role="gridcell"]')).toHaveAttribute(
370
+ 'aria-selected',
371
+ 'true',
372
+ );
373
+ expect(day15.closest('[role="gridcell"]')).toHaveAttribute(
374
+ 'aria-selected',
375
+ 'true',
376
+ );
377
+ expect(day10).not.toHaveAttribute('aria-selected');
378
+ expect(day12).not.toHaveAttribute('aria-selected');
379
+ expect(day15).not.toHaveAttribute('aria-selected');
316
380
  });
317
381
 
318
382
  it('caps the range highlight next to a disabled mid-range day (#2715)', () => {
@@ -675,4 +739,48 @@ describe('Calendar', () => {
675
739
  }
676
740
  }
677
741
  });
742
+
743
+ // ─── RTL (#3388) ─────────────────────────────────────────────
744
+
745
+ describe('RTL month navigation', () => {
746
+ // jsdom does not apply compiled StyleX CSS, so the scaleX(-1) mirror
747
+ // itself is only observable in a browser (see the dir="rtl" Storybook
748
+ // story). These tests pin the structure the fix depends on: both nav
749
+ // chevrons render inside the navIcon wrapper that carries the
750
+ // ':is([dir="rtl"] *)' conditional transform.
751
+ it('wraps both nav chevrons in the RTL-mirroring navIcon wrapper', () => {
752
+ render(<Calendar focusDate="2026-01-01" />);
753
+
754
+ const {className: navIconClass} = stylex.props(calendarStyles.navIcon);
755
+ expect(navIconClass).toBeTruthy();
756
+
757
+ for (const name of ['Previous month', 'Next month']) {
758
+ const button = screen.getByRole('button', {name});
759
+ const wrappers = Array.from(button.querySelectorAll('span')).filter(
760
+ span => span.className === navIconClass,
761
+ );
762
+ expect(wrappers.length).toBe(1);
763
+ }
764
+ });
765
+
766
+ it('keeps navigation semantics unchanged under dir="rtl"', async () => {
767
+ const user = userEvent.setup();
768
+
769
+ render(
770
+ <div dir="rtl">
771
+ <Calendar focusDate="2026-02-01" />
772
+ </div>,
773
+ );
774
+
775
+ expect(screen.getByText('February 2026')).toBeInTheDocument();
776
+
777
+ // DOM order and handlers must not change in RTL: flexbox already
778
+ // places "Previous month" at the visual right; only the glyph mirrors.
779
+ await user.click(screen.getByRole('button', {name: 'Previous month'}));
780
+ expect(screen.getByText('January 2026')).toBeInTheDocument();
781
+
782
+ await user.click(screen.getByRole('button', {name: 'Next month'}));
783
+ expect(screen.getByText('February 2026')).toBeInTheDocument();
784
+ });
785
+ });
678
786
  });
@@ -203,7 +203,7 @@ export function Calendar({ref, ...props}: CalendarProps) {
203
203
  value,
204
204
  defaultValue,
205
205
  onChange,
206
- numberOfMonths = 1,
206
+ numberOfMonths: numberOfMonthsProp = 1,
207
207
  min,
208
208
  max,
209
209
  dateConstraints,
@@ -223,6 +223,11 @@ export function Calendar({ref, ...props}: CalendarProps) {
223
223
  // DayOfWeek so all downstream date math keeps working with an index.
224
224
  const weekStartsOn = normalizeDayOfWeek(weekStartsOnProp);
225
225
 
226
+ // `numberOfMonths` is typed `1 | 2`; defensively clamp anything else that
227
+ // slips through at runtime to 1 so `Array.from({length})` can't render an
228
+ // absurd number of month grids (e.g. `numberOfMonths={1000}`).
229
+ const numberOfMonths = numberOfMonthsProp === 2 ? 2 : 1;
230
+
226
231
  // Today's date (memoized)
227
232
  const today = useMemo(() => plainDateToday(), []);
228
233
 
@@ -426,7 +431,14 @@ export function Calendar({ref, ...props}: CalendarProps) {
426
431
  <Button
427
432
  label="Previous month"
428
433
  variant="ghost"
429
- icon={<Icon icon="chevronLeft" size="sm" color="inherit" />}
434
+ icon={
435
+ // Wrapper span (not Icon props): Icon's string mode clobbers
436
+ // caller classNames, so the RTL mirror must live on its own
437
+ // element.
438
+ <span {...stylex.props(calendarStyles.navIcon)}>
439
+ <Icon icon="chevronLeft" size="sm" color="inherit" />
440
+ </span>
441
+ }
430
442
  onClick={() => navigateMonth(-1)}
431
443
  isDisabled={!canNavigatePrevious}
432
444
  isIconOnly
@@ -439,7 +451,11 @@ export function Calendar({ref, ...props}: CalendarProps) {
439
451
  <Button
440
452
  label="Next month"
441
453
  variant="ghost"
442
- icon={<Icon icon="chevronRight" size="sm" color="inherit" />}
454
+ icon={
455
+ <span {...stylex.props(calendarStyles.navIcon)}>
456
+ <Icon icon="chevronRight" size="sm" color="inherit" />
457
+ </span>
458
+ }
443
459
  onClick={() => navigateMonth(1)}
444
460
  isDisabled={!canNavigateNext}
445
461
  isIconOnly
@@ -912,7 +928,10 @@ function DayCell({
912
928
  });
913
929
 
914
930
  return (
915
- <div role="gridcell" {...stylex.props(dayCellStyles.cell)}>
931
+ <div
932
+ role="gridcell"
933
+ aria-selected={state.isSelected || state.isInRange || undefined}
934
+ {...stylex.props(dayCellStyles.cell)}>
916
935
  {/* Range background */}
917
936
  {state.isInRange && (
918
937
  <div
@@ -952,8 +971,10 @@ function DayCell({
952
971
  type="button"
953
972
  data-date={day.iso}
954
973
  aria-label={plainDateFormat(date, DATE_FORMAT_WITH_WEEKDAY)}
955
- aria-selected={state.isSelected || state.isInRange || undefined}
956
974
  aria-disabled={state.effectivelyDisabled || undefined}
975
+ // Mark today's cell programmatically (APG date-picker pattern), not just
976
+ // visually, so screen-reader users can identify the current date.
977
+ aria-current={state.isToday ? 'date' : undefined}
957
978
  disabled={isDisabled}
958
979
  // Initial roving tab-stop seed; useGridFocus owns it after mount.
959
980
  tabIndex={isTabbableDay ? 0 : -1}
@@ -64,9 +64,19 @@ export const calendarStyles = stylex.create({
64
64
  whiteSpace: 'nowrap',
65
65
  borderWidth: 0,
66
66
  },
67
+ /**
68
+ * Wrapper for the month nav chevrons. In RTL the flex header already
69
+ * swaps the buttons' visual sides; the glyphs must mirror with them so
70
+ * "Previous month" points outward (visually right) instead of inward.
71
+ * Keyed on the dir attribute (dir="rtl"), same as MobileNav's drawer
72
+ * transform — bare CSS `direction: rtl` alone won't trigger it.
73
+ */
67
74
  navIcon: {
68
- width: spacingVars['--spacing-4'],
69
- height: spacingVars['--spacing-4'],
75
+ display: 'inline-flex',
76
+ transform: {
77
+ default: null,
78
+ ':is([dir="rtl"] *)': 'scaleX(-1)',
79
+ },
70
80
  },
71
81
  });
72
82
 
package/src/Card/Card.tsx CHANGED
@@ -44,8 +44,10 @@ import {themeProps} from '../utils/themeProps';
44
44
  * - Non-semantic palette: `blue | cyan | gray | green | orange | pink | purple | red | teal | yellow`
45
45
  * Each uses the corresponding `--color-background-<name>` token (20% opacity tint).
46
46
  *
47
- * All variants include a transparent border to prevent layout jitter
48
- * when switching variants. Themes can override borderWidth/borderColor.
47
+ * Only `default` draws a visible border. Its border width is subtracted from
48
+ * the padding so the total inset (border + padding) equals the padding token —
49
+ * keeping content geometry faithful to the spacing scale and identical to the
50
+ * borderless variants. Themes can override borderWidth/borderColor.
49
51
  */
50
52
  export type CardVariant =
51
53
  | 'default'
@@ -71,12 +73,18 @@ const styles = stylex.create({
71
73
  '--_card-radius': radiusVars['--radius-container'],
72
74
  borderRadius: 'var(--_card-radius)',
73
75
  overflow: 'clip',
74
- borderWidth: borderVars['--border-width'],
75
- borderStyle: 'solid',
76
- borderColor: 'transparent',
77
76
  },
77
+ // The border is drawn *inside* the padding — its width is subtracted from
78
+ // each side — so total inset (border + padding) equals the padding token
79
+ // rather than exceeding it by the border width.
78
80
  withBorder: {
81
+ borderWidth: borderVars['--border-width'],
82
+ borderStyle: 'solid',
79
83
  borderColor: colorVars['--color-border-emphasized'],
84
+ paddingInlineStart: `calc(var(--container-padding-inline-start) - ${borderVars['--border-width']})`,
85
+ paddingInlineEnd: `calc(var(--container-padding-inline-end) - ${borderVars['--border-width']})`,
86
+ paddingBlockStart: `calc(var(--container-padding-block-start) - ${borderVars['--border-width']})`,
87
+ paddingBlockEnd: `calc(var(--container-padding-block-end) - ${borderVars['--border-width']})`,
80
88
  },
81
89
  // Fixed-height cards scroll content; overflow: auto also clips to border-radius
82
90
  scrollable: {
@@ -266,7 +274,6 @@ export function Card({
266
274
  stylex.props(
267
275
  styles.card,
268
276
  variantStyles[variant],
269
- variant === 'default' && styles.withBorder,
270
277
  hasFixedHeight && styles.scrollable,
271
278
  dynamicStyles.sizing(
272
279
  width ?? null,
@@ -296,6 +303,9 @@ export function Card({
296
303
  !useThemeDefault &&
297
304
  effectivePadding !== 4 &&
298
305
  containerPaddingBlockEndVarStyles[effectivePadding],
306
+ // Applied after the container padding so the border-inset calc wins;
307
+ // it reads the --container-padding-* vars set above.
308
+ variant === 'default' && styles.withBorder,
299
309
  xstyle,
300
310
  ),
301
311
  className,