@astryxdesign/core 0.1.1 → 0.1.2-canary.3893cd5

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 (169) hide show
  1. package/CHANGELOG.md +105 -0
  2. package/README.md +119 -1
  3. package/dist/AvatarGroup/AvatarGroupOverflow.d.ts +1 -1
  4. package/dist/AvatarGroup/AvatarGroupOverflow.d.ts.map +1 -1
  5. package/dist/AvatarGroup/AvatarGroupOverflow.js +4 -1
  6. package/dist/Banner/Banner.d.ts +7 -0
  7. package/dist/Banner/Banner.d.ts.map +1 -1
  8. package/dist/Banner/Banner.js +9 -2
  9. package/dist/Button/Button.d.ts +12 -2
  10. package/dist/Button/Button.d.ts.map +1 -1
  11. package/dist/Button/Button.js +51 -13
  12. package/dist/EmptyState/EmptyState.d.ts.map +1 -1
  13. package/dist/EmptyState/EmptyState.js +7 -1
  14. package/dist/HoverCard/HoverCard.d.ts +2 -2
  15. package/dist/HoverCard/HoverCard.d.ts.map +1 -1
  16. package/dist/HoverCard/HoverCard.js +18 -6
  17. package/dist/HoverCard/useHoverCard.d.ts.map +1 -1
  18. package/dist/HoverCard/useHoverCard.js +7 -3
  19. package/dist/Kbd/Kbd.d.ts.map +1 -1
  20. package/dist/Kbd/Kbd.js +1 -1
  21. package/dist/Layer/anchorName.d.ts +20 -0
  22. package/dist/Layer/anchorName.d.ts.map +1 -0
  23. package/dist/Layer/anchorName.js +36 -0
  24. package/dist/Layer/useLayer.d.ts +19 -0
  25. package/dist/Layer/useLayer.d.ts.map +1 -1
  26. package/dist/Layer/useLayer.js +15 -6
  27. package/dist/Link/Link.d.ts +1 -1
  28. package/dist/Link/Link.js +2 -2
  29. package/dist/Link/LinkProvider.d.ts +1 -0
  30. package/dist/Link/LinkProvider.d.ts.map +1 -1
  31. package/dist/Link/LinkProvider.js +1 -0
  32. package/dist/Markdown/Markdown.d.ts.map +1 -1
  33. package/dist/Markdown/Markdown.js +13 -3
  34. package/dist/MobileNav/MobileNav.d.ts.map +1 -1
  35. package/dist/MobileNav/MobileNav.js +13 -0
  36. package/dist/Pagination/Pagination.d.ts.map +1 -1
  37. package/dist/Pagination/Pagination.js +31 -27
  38. package/dist/Slider/Slider.js +2 -2
  39. package/dist/Table/BaseTable.d.ts.map +1 -1
  40. package/dist/Table/BaseTable.js +27 -8
  41. package/dist/Table/Table.d.ts.map +1 -1
  42. package/dist/Table/Table.js +30 -7
  43. package/dist/Table/TableRow.d.ts +6 -1
  44. package/dist/Table/TableRow.d.ts.map +1 -1
  45. package/dist/Table/TableRow.js +15 -7
  46. package/dist/Table/index.d.ts +3 -1
  47. package/dist/Table/index.d.ts.map +1 -1
  48. package/dist/Table/index.js +1 -0
  49. package/dist/Table/plugins/stickyColumns/index.d.ts +3 -0
  50. package/dist/Table/plugins/stickyColumns/index.d.ts.map +1 -0
  51. package/dist/Table/plugins/stickyColumns/index.js +3 -0
  52. package/dist/Table/plugins/stickyColumns/useTableStickyColumns.d.ts +25 -0
  53. package/dist/Table/plugins/stickyColumns/useTableStickyColumns.d.ts.map +1 -0
  54. package/dist/Table/plugins/stickyColumns/useTableStickyColumns.js +369 -0
  55. package/dist/Table/types.d.ts +95 -5
  56. package/dist/Table/types.d.ts.map +1 -1
  57. package/dist/Table/useBaseTablePlugins.d.ts.map +1 -1
  58. package/dist/Table/useBaseTablePlugins.js +1 -1
  59. package/dist/Text/text.stylex.d.ts +2 -2
  60. package/dist/Text/text.stylex.js +2 -2
  61. package/dist/Timestamp/Timestamp.d.ts +1 -1
  62. package/dist/Timestamp/Timestamp.d.ts.map +1 -1
  63. package/dist/Timestamp/Timestamp.js +28 -3
  64. package/dist/ToggleButton/ToggleButton.d.ts +7 -9
  65. package/dist/ToggleButton/ToggleButton.d.ts.map +1 -1
  66. package/dist/ToggleButton/ToggleButton.js +34 -57
  67. package/dist/Tokenizer/Tokenizer.d.ts.map +1 -1
  68. package/dist/Tokenizer/Tokenizer.js +1 -1
  69. package/dist/Tooltip/useTooltip.d.ts.map +1 -1
  70. package/dist/Tooltip/useTooltip.js +1 -0
  71. package/dist/astryx.css +30 -1
  72. package/dist/astryx.umd.js +147 -0
  73. package/dist/astryx.umd.js.map +7 -0
  74. package/dist/theme/generateThemeRules.js +1 -1
  75. package/dist/theme/syntax/SyntaxTheme.d.ts +1 -1
  76. package/dist/theme/syntax/SyntaxTheme.js +1 -1
  77. package/dist/theme/syntax/defineSyntaxTheme.d.ts.map +1 -1
  78. package/dist/theme/syntax/defineSyntaxTheme.js +2 -2
  79. package/dist/theme/syntax/presets.d.ts +1 -1
  80. package/dist/theme/syntax/presets.d.ts.map +1 -1
  81. package/dist/theme/syntax/presets.js +1 -1
  82. package/dist/theme/syntax/tokens.d.ts +1 -1
  83. package/dist/theme/syntax/tokens.js +1 -1
  84. package/dist/theme/types.d.ts +1 -1
  85. package/package.json +6 -2
  86. package/src/AvatarGroup/AvatarGroupOverflow.tsx +3 -0
  87. package/src/Banner/Banner.doc.mjs +8 -0
  88. package/src/Banner/Banner.test.tsx +16 -7
  89. package/src/Banner/Banner.tsx +9 -2
  90. package/src/Button/Button.doc.mjs +6 -0
  91. package/src/Button/Button.test.tsx +105 -11
  92. package/src/Button/Button.tsx +73 -5
  93. package/src/Chat/ChatComposer.doc.mjs +3 -0
  94. package/src/Chat/ChatComposerDrawer.doc.mjs +12 -0
  95. package/src/CheckboxInput/CheckboxInput.doc.mjs +8 -0
  96. package/src/ClickableCard/ClickableCard.doc.mjs +15 -0
  97. package/src/Dialog/Dialog.doc.mjs +2 -2
  98. package/src/EmptyState/EmptyState.test.tsx +4 -2
  99. package/src/EmptyState/EmptyState.tsx +6 -2
  100. package/src/HoverCard/HoverCard.doc.mjs +3 -0
  101. package/src/HoverCard/HoverCard.test.tsx +189 -2
  102. package/src/HoverCard/HoverCard.tsx +20 -16
  103. package/src/HoverCard/useHoverCard.tsx +13 -10
  104. package/src/Icon/Icon.doc.mjs +8 -0
  105. package/src/Kbd/Kbd.tsx +2 -9
  106. package/src/Layer/anchorName.test.ts +146 -0
  107. package/src/Layer/anchorName.ts +46 -0
  108. package/src/Layer/useLayer.doc.mjs +7 -2
  109. package/src/Layer/useLayer.tsx +33 -9
  110. package/src/Layout/Layout.doc.mjs +1 -1
  111. package/src/Lightbox/Lightbox.doc.mjs +0 -2
  112. package/src/Link/Link.test.tsx +2 -2
  113. package/src/Link/Link.tsx +3 -3
  114. package/src/Link/LinkProvider.tsx +1 -0
  115. package/src/List/List.doc.mjs +7 -0
  116. package/src/Markdown/Markdown.doc.mjs +2 -0
  117. package/src/Markdown/Markdown.test.tsx +17 -26
  118. package/src/Markdown/Markdown.tsx +16 -6
  119. package/src/MobileNav/MobileNav.doc.mjs +8 -8
  120. package/src/MobileNav/MobileNav.tsx +13 -0
  121. package/src/MobileNav/MobileNavReopen.test.tsx +118 -0
  122. package/src/OverflowList/OverflowList.doc.mjs +17 -0
  123. package/src/Pagination/Pagination.test.tsx +137 -13
  124. package/src/Pagination/Pagination.tsx +33 -28
  125. package/src/ProgressBar/ProgressBar.test.tsx +13 -3
  126. package/src/Resizable/Resizable.doc.mjs +1 -1
  127. package/src/Section/Section.doc.mjs +11 -0
  128. package/src/SelectableCard/SelectableCard.doc.mjs +15 -0
  129. package/src/Selector/Selector.doc.mjs +4 -0
  130. package/src/Skeleton/Skeleton.doc.mjs +11 -1
  131. package/src/Slider/Slider.doc.mjs +4 -0
  132. package/src/Slider/Slider.test.tsx +3 -16
  133. package/src/Slider/Slider.tsx +1 -1
  134. package/src/TabList/TabList.doc.mjs +6 -0
  135. package/src/Table/BaseTable.tsx +51 -24
  136. package/src/Table/Table.doc.mjs +12 -0
  137. package/src/Table/Table.test.tsx +29 -30
  138. package/src/Table/Table.tsx +22 -1
  139. package/src/Table/TableRow.tsx +37 -7
  140. package/src/Table/index.ts +3 -0
  141. package/src/Table/plugins/stickyColumns/index.ts +4 -0
  142. package/src/Table/plugins/stickyColumns/useTableStickyColumns.test.tsx +163 -0
  143. package/src/Table/plugins/stickyColumns/useTableStickyColumns.tsx +407 -0
  144. package/src/Table/types.ts +101 -4
  145. package/src/Table/useBaseTablePlugins.ts +1 -0
  146. package/src/Table/useTableColumnResize.doc.mjs +96 -0
  147. package/src/Table/useTableStickyColumns.doc.mjs +59 -0
  148. package/src/Text/Heading.doc.mjs +15 -2
  149. package/src/Text/Text.doc.mjs +7 -1
  150. package/src/Text/text.stylex.ts +2 -2
  151. package/src/Timestamp/Timestamp.doc.mjs +9 -0
  152. package/src/Timestamp/Timestamp.test.tsx +44 -18
  153. package/src/Timestamp/Timestamp.tsx +30 -10
  154. package/src/ToggleButton/ToggleButton.doc.mjs +9 -3
  155. package/src/ToggleButton/ToggleButton.test.tsx +39 -18
  156. package/src/ToggleButton/ToggleButton.tsx +46 -83
  157. package/src/Tokenizer/Tokenizer.tsx +4 -1
  158. package/src/Toolbar/Toolbar.doc.mjs +65 -5
  159. package/src/Tooltip/Tooltip.test.tsx +13 -0
  160. package/src/Tooltip/useTooltip.tsx +2 -3
  161. package/src/hooks/useImageMode.test.ts +133 -0
  162. package/src/theme/derivedVarRegistry.test.ts +4 -0
  163. package/src/theme/generateThemeRules.test.ts +7 -8
  164. package/src/theme/generateThemeRules.ts +1 -1
  165. package/src/theme/syntax/SyntaxTheme.tsx +1 -1
  166. package/src/theme/syntax/defineSyntaxTheme.ts +20 -11
  167. package/src/theme/syntax/presets.ts +5 -2
  168. package/src/theme/syntax/tokens.ts +1 -1
  169. package/src/theme/types.ts +1 -1
@@ -0,0 +1,59 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /** @type {import('../docs-types').ComponentDoc} */
4
+
5
+ export const docs = {
6
+ name: 'useTableStickyColumns',
7
+ subComponentOf: 'Table',
8
+ displayName: 'useTableStickyColumns',
9
+ description:
10
+ 'Hook that returns a TablePlugin which pins a contiguous run of columns to the start and/or end edge of the table. Pinned columns get cumulative offsets and a soft, scroll-aware shadow over the scrolling content. An empty config ({}) is a valid no-op that pins nothing.',
11
+ props: [
12
+ {
13
+ name: 'startKeys',
14
+ type: 'string[]',
15
+ description:
16
+ 'Column keys pinned to the start (inline-start / left in LTR) edge — the contiguous run from the first column through the last listed key.',
17
+ },
18
+ {
19
+ name: 'endKeys',
20
+ type: 'string[]',
21
+ description:
22
+ 'Column keys pinned to the end (inline-end / right in LTR) edge — the contiguous run from the first listed key through the last column.',
23
+ },
24
+ ],
25
+ };
26
+
27
+ export const docsZh = {
28
+ name: 'useTableStickyColumns',
29
+ displayName: 'useTableStickyColumns',
30
+ description:
31
+ '返回 TablePlugin 的 Hook,将一组连续的列固定到表格的起始和/或结束边缘。固定的列具有累积偏移量,并在滚动内容上方显示柔和的、随滚动感知的阴影。空配置 ({}) 是有效的空操作,不固定任何列。',
32
+ props: [
33
+ {
34
+ name: 'startKeys',
35
+ type: 'string[]',
36
+ description:
37
+ '固定到起始(inline-start / LTR 中为左侧)边缘的列键——从第一列到最后列出的键的连续范围。',
38
+ },
39
+ {
40
+ name: 'endKeys',
41
+ type: 'string[]',
42
+ description:
43
+ '固定到结束(inline-end / LTR 中为右侧)边缘的列键——从第一个列出的键到最后一列的连续范围。',
44
+ },
45
+ ],
46
+ };
47
+
48
+ export const docsDense = {
49
+ name: 'useTableStickyColumns',
50
+ displayName: 'useTableStickyColumns',
51
+ description:
52
+ 'Hook returning TablePlugin that pins contiguous column runs to start/end edges w/ cumulative offsets + scroll-aware shadow. Empty config = no-op.',
53
+ propDescriptions: {
54
+ startKeys:
55
+ 'Column keys pinned to the start edge (contiguous run from column 0 through the last listed key).',
56
+ endKeys:
57
+ 'Column keys pinned to the end edge (contiguous run from the first listed key through the last column).',
58
+ },
59
+ };
@@ -32,7 +32,7 @@ export const docs = {
32
32
  },
33
33
  {
34
34
  name: 'color',
35
- type: "'primary' | 'secondary' | 'disabled' | 'placeholder' | 'active' | 'inherit'",
35
+ type: "'primary' | 'secondary' | 'disabled' | 'placeholder' | 'accent' | 'inherit'",
36
36
  description: 'Text color.',
37
37
  default: "'primary'",
38
38
  },
@@ -64,6 +64,12 @@ export const docs = {
64
64
  type: "'wrap' | 'nowrap' | 'balance' | 'pretty'",
65
65
  description: 'Text wrapping behavior.',
66
66
  },
67
+ {
68
+ name: 'justify',
69
+ type: "'start' | 'center' | 'end'",
70
+ description: 'Text alignment (justification). Uses logical values (start/end) for i18n/RTL compatibility.',
71
+ default: "'start'",
72
+ },
67
73
  {
68
74
  name: 'hasCapsize',
69
75
  type: 'boolean',
@@ -113,7 +119,7 @@ export const docsZh = {
113
119
  },
114
120
  {
115
121
  name: 'color',
116
- type: "'primary' | 'secondary' | 'disabled' | 'placeholder' | 'active' | 'inherit'",
122
+ type: "'primary' | 'secondary' | 'disabled' | 'placeholder' | 'accent' | 'inherit'",
117
123
  description: '文本颜色。',
118
124
  default: "'primary'",
119
125
  },
@@ -145,6 +151,12 @@ export const docsZh = {
145
151
  type: "'wrap' | 'nowrap' | 'balance' | 'pretty'",
146
152
  description: '文本换行行为。',
147
153
  },
154
+ {
155
+ name: 'justify',
156
+ type: "'start' | 'center' | 'end'",
157
+ description: '文本对齐(两端对齐)。使用逻辑值(start/end)以兼容 i18n/RTL。',
158
+ default: "'start'",
159
+ },
148
160
  {
149
161
  name: 'hasCapsize',
150
162
  type: 'boolean',
@@ -180,6 +192,7 @@ export const docsDense = {
180
192
  hasTruncateTooltip: "Tooltip for truncated text; true=default position, false=disabled, or a placement ('above' | 'below' | 'start' | 'end').",
181
193
  wordBreak: "Word break behavior; defaults 'break-all' for single-line, 'break-word' otherwise.",
182
194
  textWrap: 'Text wrapping behavior.',
195
+ justify: "Text alignment (justification); logical values 'start' | 'center' | 'end' for RTL.",
183
196
  hasCapsize: 'Optical alignment via text-box-trim; forces block display.',
184
197
  hasStrikethrough: 'Strikethrough text decoration.',
185
198
  id: 'HTML id attribute.',
@@ -40,7 +40,7 @@ export const docs = {
40
40
  },
41
41
  {
42
42
  name: 'color',
43
- type: "'primary' | 'secondary' | 'disabled' | 'placeholder' | 'active' | 'inherit'",
43
+ type: "'primary' | 'secondary' | 'disabled' | 'placeholder' | 'accent' | 'inherit'",
44
44
  description: "Text color. Defaults to 'secondary' for the 'supporting' type, 'primary' for all others.",
45
45
  },
46
46
  {
@@ -82,6 +82,12 @@ export const docs = {
82
82
  type: "'wrap' | 'nowrap' | 'balance' | 'pretty'",
83
83
  description: 'Text wrapping behavior.',
84
84
  },
85
+ {
86
+ name: 'justify',
87
+ type: "'start' | 'center' | 'end'",
88
+ description: 'Text alignment (justification). Uses logical values (start/end) for i18n/RTL compatibility.',
89
+ default: "'start'",
90
+ },
85
91
  {
86
92
  name: 'hasCapsize',
87
93
  type: 'boolean',
@@ -35,8 +35,8 @@ export const colorStyles = stylex.create({
35
35
  placeholder: {
36
36
  color: colorVars['--color-text-secondary'],
37
37
  },
38
- active: {
39
- color: colorVars['--color-accent'],
38
+ accent: {
39
+ color: colorVars['--color-text-accent'],
40
40
  },
41
41
  inherit: {
42
42
  color: 'inherit',
@@ -7,6 +7,15 @@ export const docs = {
7
7
  displayName: 'Timestamp',
8
8
  category: 'Content',
9
9
  keywords: ['date', 'time', 'datetime', 'relative', 'ago', 'clock', 'format', 'duration'],
10
+ playground: {
11
+ // `value` is required but has no semantic default, so the properties-tab
12
+ // preview fell back to the literal string "value" — which
13
+ // `new Date("value")` rejects, crashing the preview with "Invalid time
14
+ // value". Seed a valid ISO 8601 date so the interactive preview renders.
15
+ defaults: {
16
+ value: '2026-02-19T17:00:00Z',
17
+ },
18
+ },
10
19
  props: [
11
20
  {
12
21
  name: 'value',
@@ -33,10 +33,30 @@ describe('Timestamp', () => {
33
33
  expect(screen.getByText('2 hours ago')).toBeInTheDocument();
34
34
  });
35
35
 
36
- it('renders "just now" for very recent times', () => {
36
+ it('renders "now" for very recent times', () => {
37
37
  const fiveSecondsAgo = Date.now() / 1000 - 5;
38
38
  render(<Timestamp value={fiveSecondsAgo} format="relative" />);
39
- expect(screen.getByText('just now')).toBeInTheDocument();
39
+ expect(screen.getByText('now')).toBeInTheDocument();
40
+ });
41
+
42
+ it('renders "now" for the current instant (not a future phrase)', () => {
43
+ // A value equal to "right now". Because the internal `now` baseline is
44
+ // captured at render time, it can lag the value by a fraction of a second,
45
+ // producing a tiny negative delta that must not be treated as the future.
46
+ render(<Timestamp value={Date.now() / 1000} format="relative" />);
47
+ expect(screen.queryByText(/^in /)).not.toBeInTheDocument();
48
+ expect(screen.getByText('now')).toBeInTheDocument();
49
+ });
50
+
51
+ it('renders "now" for a value a hair in the future (clock skew)', () => {
52
+ // Real-world clock / captured-now skew can make a current-ish value land a
53
+ // fraction of a second in the future relative to the component's internal
54
+ // `now`. This must read as the present ("now"), never "in a few
55
+ // seconds". Regression test for the right-now -> future-phrase bug.
56
+ const aHairInTheFuture = Date.now() / 1000 + 0.6;
57
+ render(<Timestamp value={aHairInTheFuture} format="relative" />);
58
+ expect(screen.queryByText(/^in /)).not.toBeInTheDocument();
59
+ expect(screen.getByText('now')).toBeInTheDocument();
40
60
  });
41
61
 
42
62
  it('renders "yesterday" for times ~1 day ago', () => {
@@ -49,11 +69,7 @@ describe('Timestamp', () => {
49
69
 
50
70
  it('renders date format', () => {
51
71
  render(
52
- <Timestamp
53
- value="2026-02-19T17:00:00Z"
54
- format="date"
55
- data-testid="ts"
56
- />,
72
+ <Timestamp value="2026-02-19T17:00:00Z" format="date" data-testid="ts" />,
57
73
  );
58
74
  const el = screen.getByTestId('ts');
59
75
  expect(el.textContent).toContain('2026');
@@ -77,11 +93,7 @@ describe('Timestamp', () => {
77
93
 
78
94
  it('renders time format', () => {
79
95
  render(
80
- <Timestamp
81
- value="2026-02-19T17:00:00Z"
82
- format="time"
83
- data-testid="ts"
84
- />,
96
+ <Timestamp value="2026-02-19T17:00:00Z" format="time" data-testid="ts" />,
85
97
  );
86
98
  const el = screen.getByTestId('ts');
87
99
  // Should contain time but not year
@@ -197,7 +209,7 @@ describe('Timestamp', () => {
197
209
  it('live updates relative time', () => {
198
210
  const now = Date.now() / 1000;
199
211
  render(<Timestamp value={now - 5} format="relative" isLive />);
200
- expect(screen.getByText('just now')).toBeInTheDocument();
212
+ expect(screen.getByText('now')).toBeInTheDocument();
201
213
 
202
214
  act(() => {
203
215
  vi.advanceTimersByTime(30_000);
@@ -210,11 +222,7 @@ describe('Timestamp', () => {
210
222
  it('forwards ref', () => {
211
223
  const ref = {current: null as HTMLTimeElement | null};
212
224
  render(
213
- <Timestamp
214
- ref={ref}
215
- value="2026-03-25T10:00:00Z"
216
- format="date_time"
217
- />,
225
+ <Timestamp ref={ref} value="2026-03-25T10:00:00Z" format="date_time" />,
218
226
  );
219
227
  expect(ref.current).toBeInstanceOf(HTMLTimeElement);
220
228
  });
@@ -240,6 +248,24 @@ describe('Timestamp', () => {
240
248
  expect(screen.getByText('in 1 hour')).toBeInTheDocument();
241
249
  });
242
250
 
251
+ it('renders "now" for a value a few seconds in the future (clock skew)', () => {
252
+ // Beyond the sub-second render lag but still within the skew tolerance: a
253
+ // value ~20s ahead of our clock is almost always skew (the value's clock
254
+ // running fast), not a genuine future event, so it should read as the
255
+ // present rather than "in a few seconds".
256
+ const twentySecondsFromNow = Date.now() / 1000 + 20;
257
+ render(<Timestamp value={twentySecondsFromNow} format="relative" />);
258
+ expect(screen.queryByText(/^in /)).not.toBeInTheDocument();
259
+ expect(screen.getByText('now')).toBeInTheDocument();
260
+ });
261
+
262
+ it('renders a genuine near-future time beyond the skew tolerance', () => {
263
+ // Past the skew window — this is a real upcoming time, not clock drift.
264
+ const fortyFiveSecondsFromNow = Date.now() / 1000 + 45;
265
+ render(<Timestamp value={fortyFiveSecondsFromNow} format="relative" />);
266
+ expect(screen.getByText('in a few seconds')).toBeInTheDocument();
267
+ });
268
+
243
269
  // --- Long-ago relative ---
244
270
 
245
271
  it('renders months ago for dates older than 30 days', () => {
@@ -19,12 +19,7 @@
19
19
  import {useEffect, useRef, useState, lazy, Suspense} from 'react';
20
20
  import * as stylex from '@stylexjs/stylex';
21
21
  import {Text} from '../Text';
22
- import type {
23
- TextType,
24
- TextSize,
25
- TextColor,
26
- TextWeight,
27
- } from '../theme/types';
22
+ import type {TextType, TextSize, TextColor, TextWeight} from '../theme/types';
28
23
  import {mergeProps, mergeRefs} from '../utils';
29
24
  import type {BaseProps} from '../BaseProps';
30
25
  import {themeProps} from '../utils/themeProps';
@@ -54,7 +49,7 @@ export interface TimestampProps extends BaseProps<HTMLTimeElement> {
54
49
  value: string | number;
55
50
  /**
56
51
  * Display format.
57
- * - `'relative'`: "2 hours ago", "yesterday", "just now"
52
+ * - `'relative'`: "2 hours ago", "yesterday", "now"
58
53
  * - `'auto'`: Relative for recent times, `date_time` for older
59
54
  * - `'date'`: "Mar 21, 2025"
60
55
  * - `'date_time'`: "Mar 21, 2025, 2:51 PM"
@@ -138,6 +133,18 @@ const YEAR = 365 * DAY;
138
133
  /** Default auto threshold: 7 days in seconds */
139
134
  const DEFAULT_AUTO_THRESHOLD = 7 * DAY;
140
135
 
136
+ /**
137
+ * Tolerance (in seconds) for treating a *future* timestamp as the present.
138
+ * A value only a handful of seconds ahead of our reference clock is almost
139
+ * always clock skew — the displayed `now` lagging the real clock, or the value
140
+ * being produced on a slightly faster clock — not a genuine future event, so
141
+ * it reads as "now" rather than a confusing "in a few seconds". The future
142
+ * side gets a wider window than the past (which only needs to absorb the
143
+ * sub-second render-time lag) because future drift is far more likely to be
144
+ * skew than real.
145
+ */
146
+ const FUTURE_SKEW_TOLERANCE = 30;
147
+
141
148
  function parseValue(value: string | number): Date {
142
149
  if (typeof value === 'number') {
143
150
  // Heuristic: if the number is less than 1e12, treat as seconds; otherwise ms.
@@ -150,9 +157,25 @@ function parseValue(value: string | number): Date {
150
157
  function getRelativeTimeString(date: Date, now: Date): string {
151
158
  const diffSeconds = Math.round((now.getTime() - date.getTime()) / 1000);
152
159
 
160
+ // Treat values at (or a hair before/after) the present as "now". The
161
+ // internal `now` reference is captured at render time, so it can lag the
162
+ // real clock; a value equal to "right now" can land a fraction of a second
163
+ // in the future and round to a small negative delta. Without this clamp,
164
+ // such values fall into the future branch and render "in a few seconds".
165
+ if (Math.abs(diffSeconds) < 10) {
166
+ return 'now';
167
+ }
168
+
153
169
  if (diffSeconds < 0) {
154
170
  // Future dates
155
171
  const absDiff = Math.abs(diffSeconds);
172
+ // A value only a few seconds ahead of our clock is almost always skew, not
173
+ // a genuine future event — render it as the present rather than a
174
+ // confusing "in a few seconds". Wider than the past window above on
175
+ // purpose (see FUTURE_SKEW_TOLERANCE).
176
+ if (absDiff <= FUTURE_SKEW_TOLERANCE) {
177
+ return 'now';
178
+ }
156
179
  if (absDiff < MINUTE) {
157
180
  return 'in a few seconds';
158
181
  }
@@ -176,9 +199,6 @@ function getRelativeTimeString(date: Date, now: Date): string {
176
199
  return `in ${years} ${years === 1 ? 'year' : 'years'}`;
177
200
  }
178
201
 
179
- if (diffSeconds < 10) {
180
- return 'just now';
181
- }
182
202
  if (diffSeconds < MINUTE) {
183
203
  return `${diffSeconds} seconds ago`;
184
204
  }
@@ -34,13 +34,13 @@ export const docs = {
34
34
  },
35
35
  {
36
36
  name: 'onPressedChange',
37
- type: '(isPressed: boolean) => void',
38
- description: 'Called when pressed state should change. Ignored when inside a group.',
37
+ type: '(isPressed: boolean, event: MouseEvent) => void',
38
+ description: 'Called when pressed state should change. Receives the next state and the click event; call event.preventDefault() to skip pressedChangeAction. Ignored when inside a group.',
39
39
  },
40
40
  {
41
41
  name: 'pressedChangeAction',
42
42
  type: '(isPressed: boolean) => void | Promise<void>',
43
- description: 'Action handler for API- or navigation-backed toggles, run in a transition. Shows an optimistic pressed state immediately and a (debounced) spinner while pending; interruptible by re-clicks.',
43
+ description: 'Action handler for API- or navigation-backed toggles, run in a transition. Shows an optimistic pressed state immediately and a spinner while pending; the button stays interruptible by re-clicks.',
44
44
  },
45
45
  {
46
46
  name: 'size',
@@ -74,6 +74,12 @@ export const docs = {
74
74
  },
75
75
  ],
76
76
  },
77
+ {
78
+ name: 'isIconOnly',
79
+ type: 'boolean',
80
+ description: 'When true, renders as a square icon-only button with `label` as the aria-label and an automatic tooltip from the label.',
81
+ default: 'false',
82
+ },
77
83
  {
78
84
  name: 'pressedIcon',
79
85
  type: 'ReactNode',
@@ -9,9 +9,9 @@
9
9
  */
10
10
 
11
11
  import {describe, it, expect, vi} from 'vitest';
12
- import {render, screen, act, fireEvent, waitFor} from '@testing-library/react';
12
+ import {render, screen, act, fireEvent} from '@testing-library/react';
13
13
  import userEvent from '@testing-library/user-event';
14
- import {useState} from 'react';
14
+ import {useState, type MouseEvent} from 'react';
15
15
  import {ToggleButton} from './ToggleButton';
16
16
  import {ToggleButtonGroup} from './ToggleButtonGroup';
17
17
 
@@ -88,7 +88,7 @@ describe('ToggleButton', () => {
88
88
  );
89
89
 
90
90
  await user.click(screen.getByRole('button'));
91
- expect(handleChange).toHaveBeenCalledWith(true);
91
+ expect(handleChange).toHaveBeenCalledWith(true, expect.anything());
92
92
  });
93
93
 
94
94
  it('calls onPressedChange with false when clicking pressed button', async () => {
@@ -103,7 +103,7 @@ describe('ToggleButton', () => {
103
103
  );
104
104
 
105
105
  await user.click(screen.getByRole('button'));
106
- expect(handleChange).toHaveBeenCalledWith(false);
106
+ expect(handleChange).toHaveBeenCalledWith(false, expect.anything());
107
107
  });
108
108
 
109
109
  it('renders pressedIcon when pressed', () => {
@@ -193,7 +193,7 @@ describe('ToggleButton', () => {
193
193
  expect(screen.getByTestId('bold-toggle')).toBeInTheDocument();
194
194
  });
195
195
 
196
- it('shows the optimistic pressed state immediately, before any spinner', async () => {
196
+ it('shows the optimistic pressed state and stays interruptible while pending', async () => {
197
197
  const user = userEvent.setup();
198
198
  let resolveAction: (() => void) | undefined;
199
199
  const pressedChangeAction = vi.fn(
@@ -217,12 +217,13 @@ describe('ToggleButton', () => {
217
217
 
218
218
  await user.click(button);
219
219
 
220
- // The optimistic state flips immediately. The spinner is debounced, so the
221
- // button is not disabled or aria-busy yet — it stays interruptible.
220
+ // The optimistic state flips immediately and the spinner shows via
221
+ // aria-busy, but the button is never disabled — it stays clickable so the
222
+ // action can be interrupted by another click.
222
223
  expect(pressedChangeAction).toHaveBeenCalledWith(true);
223
224
  expect(button).toHaveAttribute('aria-pressed', 'true');
225
+ expect(button).toHaveAttribute('aria-busy', 'true');
224
226
  expect(button).not.toBeDisabled();
225
- expect(button).not.toHaveAttribute('aria-busy', 'true');
226
227
 
227
228
  // Settle the action so the pending transition doesn't leak into later tests.
228
229
  await act(async () => {
@@ -231,7 +232,7 @@ describe('ToggleButton', () => {
231
232
  });
232
233
  });
233
234
 
234
- it('shows a loading spinner once the action stays pending past the delay', async () => {
235
+ it('clears the loading state once the action settles', async () => {
235
236
  const user = userEvent.setup();
236
237
  let resolveAction: (() => void) | undefined;
237
238
  const pressedChangeAction = vi.fn(
@@ -253,18 +254,15 @@ describe('ToggleButton', () => {
253
254
  const button = screen.getByRole('button', {name: 'Favorite'});
254
255
  await user.click(button);
255
256
 
256
- // The optimistic state shows immediately; the spinner is debounced and
257
- // appears only after the action stays pending past the delay window.
258
- expect(button).toHaveAttribute('aria-pressed', 'true');
259
- await waitFor(() => expect(button).toBeDisabled());
260
257
  expect(button).toHaveAttribute('aria-busy', 'true');
258
+ expect(button).not.toBeDisabled();
261
259
 
262
260
  await act(async () => {
263
261
  resolveAction?.();
264
262
  await Promise.resolve();
265
263
  });
266
- expect(button).not.toBeDisabled();
267
264
  expect(button).not.toHaveAttribute('aria-busy', 'true');
265
+ expect(button).not.toBeDisabled();
268
266
  });
269
267
 
270
268
  it('interrupts an in-flight action on re-click (true -> false -> true)', async () => {
@@ -290,9 +288,8 @@ describe('ToggleButton', () => {
290
288
  const button = screen.getByRole('button', {name: 'Favorite'});
291
289
 
292
290
  // Each click derives the next state from the optimistic (in-progress)
293
- // value, so rapid clicks toggle rather than being dropped. fireEvent keeps
294
- // the clicks within the spinner debounce window so the button stays
295
- // interruptible.
291
+ // value, so rapid clicks toggle rather than being dropped. The button is
292
+ // never disabled while pending, so every click lands and interrupts.
296
293
  await act(async () => {
297
294
  fireEvent.click(button);
298
295
  });
@@ -335,9 +332,33 @@ describe('ToggleButton', () => {
335
332
  const button = screen.getByRole('button', {name: 'Favorite'});
336
333
  await user.click(button);
337
334
 
338
- expect(onPressedChange).toHaveBeenCalledWith(true);
335
+ expect(onPressedChange).toHaveBeenCalledWith(true, expect.anything());
339
336
  expect(pressedChangeAction).toHaveBeenCalledWith(true);
340
337
  });
338
+
339
+ it('skips pressedChangeAction when onPressedChange calls preventDefault', async () => {
340
+ const user = userEvent.setup();
341
+ const pressedChangeAction = vi.fn();
342
+ const onPressedChange = vi.fn(
343
+ (_next: boolean, event: MouseEvent<HTMLButtonElement>) => {
344
+ event.preventDefault();
345
+ },
346
+ );
347
+
348
+ render(
349
+ <ToggleButton
350
+ label="Favorite"
351
+ isPressed={false}
352
+ onPressedChange={onPressedChange}
353
+ pressedChangeAction={pressedChangeAction}
354
+ />,
355
+ );
356
+
357
+ await user.click(screen.getByRole('button', {name: 'Favorite'}));
358
+
359
+ expect(onPressedChange).toHaveBeenCalledWith(true, expect.anything());
360
+ expect(pressedChangeAction).not.toHaveBeenCalled();
361
+ });
341
362
  });
342
363
 
343
364
  // =============================================================================