@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.
- package/CHANGELOG.md +105 -0
- package/README.md +119 -1
- package/dist/AvatarGroup/AvatarGroupOverflow.d.ts +1 -1
- package/dist/AvatarGroup/AvatarGroupOverflow.d.ts.map +1 -1
- package/dist/AvatarGroup/AvatarGroupOverflow.js +4 -1
- package/dist/Banner/Banner.d.ts +7 -0
- package/dist/Banner/Banner.d.ts.map +1 -1
- package/dist/Banner/Banner.js +9 -2
- package/dist/Button/Button.d.ts +12 -2
- package/dist/Button/Button.d.ts.map +1 -1
- package/dist/Button/Button.js +51 -13
- package/dist/EmptyState/EmptyState.d.ts.map +1 -1
- package/dist/EmptyState/EmptyState.js +7 -1
- package/dist/HoverCard/HoverCard.d.ts +2 -2
- package/dist/HoverCard/HoverCard.d.ts.map +1 -1
- package/dist/HoverCard/HoverCard.js +18 -6
- package/dist/HoverCard/useHoverCard.d.ts.map +1 -1
- package/dist/HoverCard/useHoverCard.js +7 -3
- package/dist/Kbd/Kbd.d.ts.map +1 -1
- package/dist/Kbd/Kbd.js +1 -1
- package/dist/Layer/anchorName.d.ts +20 -0
- package/dist/Layer/anchorName.d.ts.map +1 -0
- package/dist/Layer/anchorName.js +36 -0
- package/dist/Layer/useLayer.d.ts +19 -0
- package/dist/Layer/useLayer.d.ts.map +1 -1
- package/dist/Layer/useLayer.js +15 -6
- package/dist/Link/Link.d.ts +1 -1
- package/dist/Link/Link.js +2 -2
- package/dist/Link/LinkProvider.d.ts +1 -0
- package/dist/Link/LinkProvider.d.ts.map +1 -1
- package/dist/Link/LinkProvider.js +1 -0
- package/dist/Markdown/Markdown.d.ts.map +1 -1
- package/dist/Markdown/Markdown.js +13 -3
- package/dist/MobileNav/MobileNav.d.ts.map +1 -1
- package/dist/MobileNav/MobileNav.js +13 -0
- package/dist/Pagination/Pagination.d.ts.map +1 -1
- package/dist/Pagination/Pagination.js +31 -27
- package/dist/Slider/Slider.js +2 -2
- package/dist/Table/BaseTable.d.ts.map +1 -1
- package/dist/Table/BaseTable.js +27 -8
- package/dist/Table/Table.d.ts.map +1 -1
- package/dist/Table/Table.js +30 -7
- package/dist/Table/TableRow.d.ts +6 -1
- package/dist/Table/TableRow.d.ts.map +1 -1
- package/dist/Table/TableRow.js +15 -7
- package/dist/Table/index.d.ts +3 -1
- package/dist/Table/index.d.ts.map +1 -1
- package/dist/Table/index.js +1 -0
- package/dist/Table/plugins/stickyColumns/index.d.ts +3 -0
- package/dist/Table/plugins/stickyColumns/index.d.ts.map +1 -0
- package/dist/Table/plugins/stickyColumns/index.js +3 -0
- package/dist/Table/plugins/stickyColumns/useTableStickyColumns.d.ts +25 -0
- package/dist/Table/plugins/stickyColumns/useTableStickyColumns.d.ts.map +1 -0
- package/dist/Table/plugins/stickyColumns/useTableStickyColumns.js +369 -0
- package/dist/Table/types.d.ts +95 -5
- package/dist/Table/types.d.ts.map +1 -1
- package/dist/Table/useBaseTablePlugins.d.ts.map +1 -1
- package/dist/Table/useBaseTablePlugins.js +1 -1
- package/dist/Text/text.stylex.d.ts +2 -2
- package/dist/Text/text.stylex.js +2 -2
- package/dist/Timestamp/Timestamp.d.ts +1 -1
- package/dist/Timestamp/Timestamp.d.ts.map +1 -1
- package/dist/Timestamp/Timestamp.js +28 -3
- package/dist/ToggleButton/ToggleButton.d.ts +7 -9
- package/dist/ToggleButton/ToggleButton.d.ts.map +1 -1
- package/dist/ToggleButton/ToggleButton.js +34 -57
- package/dist/Tokenizer/Tokenizer.d.ts.map +1 -1
- package/dist/Tokenizer/Tokenizer.js +1 -1
- package/dist/Tooltip/useTooltip.d.ts.map +1 -1
- package/dist/Tooltip/useTooltip.js +1 -0
- package/dist/astryx.css +30 -1
- package/dist/astryx.umd.js +147 -0
- package/dist/astryx.umd.js.map +7 -0
- package/dist/theme/generateThemeRules.js +1 -1
- package/dist/theme/syntax/SyntaxTheme.d.ts +1 -1
- package/dist/theme/syntax/SyntaxTheme.js +1 -1
- package/dist/theme/syntax/defineSyntaxTheme.d.ts.map +1 -1
- package/dist/theme/syntax/defineSyntaxTheme.js +2 -2
- package/dist/theme/syntax/presets.d.ts +1 -1
- package/dist/theme/syntax/presets.d.ts.map +1 -1
- package/dist/theme/syntax/presets.js +1 -1
- package/dist/theme/syntax/tokens.d.ts +1 -1
- package/dist/theme/syntax/tokens.js +1 -1
- package/dist/theme/types.d.ts +1 -1
- package/package.json +6 -2
- package/src/AvatarGroup/AvatarGroupOverflow.tsx +3 -0
- package/src/Banner/Banner.doc.mjs +8 -0
- package/src/Banner/Banner.test.tsx +16 -7
- package/src/Banner/Banner.tsx +9 -2
- package/src/Button/Button.doc.mjs +6 -0
- package/src/Button/Button.test.tsx +105 -11
- package/src/Button/Button.tsx +73 -5
- package/src/Chat/ChatComposer.doc.mjs +3 -0
- package/src/Chat/ChatComposerDrawer.doc.mjs +12 -0
- package/src/CheckboxInput/CheckboxInput.doc.mjs +8 -0
- package/src/ClickableCard/ClickableCard.doc.mjs +15 -0
- package/src/Dialog/Dialog.doc.mjs +2 -2
- package/src/EmptyState/EmptyState.test.tsx +4 -2
- package/src/EmptyState/EmptyState.tsx +6 -2
- package/src/HoverCard/HoverCard.doc.mjs +3 -0
- package/src/HoverCard/HoverCard.test.tsx +189 -2
- package/src/HoverCard/HoverCard.tsx +20 -16
- package/src/HoverCard/useHoverCard.tsx +13 -10
- package/src/Icon/Icon.doc.mjs +8 -0
- package/src/Kbd/Kbd.tsx +2 -9
- package/src/Layer/anchorName.test.ts +146 -0
- package/src/Layer/anchorName.ts +46 -0
- package/src/Layer/useLayer.doc.mjs +7 -2
- package/src/Layer/useLayer.tsx +33 -9
- package/src/Layout/Layout.doc.mjs +1 -1
- package/src/Lightbox/Lightbox.doc.mjs +0 -2
- package/src/Link/Link.test.tsx +2 -2
- package/src/Link/Link.tsx +3 -3
- package/src/Link/LinkProvider.tsx +1 -0
- package/src/List/List.doc.mjs +7 -0
- package/src/Markdown/Markdown.doc.mjs +2 -0
- package/src/Markdown/Markdown.test.tsx +17 -26
- package/src/Markdown/Markdown.tsx +16 -6
- package/src/MobileNav/MobileNav.doc.mjs +8 -8
- package/src/MobileNav/MobileNav.tsx +13 -0
- package/src/MobileNav/MobileNavReopen.test.tsx +118 -0
- package/src/OverflowList/OverflowList.doc.mjs +17 -0
- package/src/Pagination/Pagination.test.tsx +137 -13
- package/src/Pagination/Pagination.tsx +33 -28
- package/src/ProgressBar/ProgressBar.test.tsx +13 -3
- package/src/Resizable/Resizable.doc.mjs +1 -1
- package/src/Section/Section.doc.mjs +11 -0
- package/src/SelectableCard/SelectableCard.doc.mjs +15 -0
- package/src/Selector/Selector.doc.mjs +4 -0
- package/src/Skeleton/Skeleton.doc.mjs +11 -1
- package/src/Slider/Slider.doc.mjs +4 -0
- package/src/Slider/Slider.test.tsx +3 -16
- package/src/Slider/Slider.tsx +1 -1
- package/src/TabList/TabList.doc.mjs +6 -0
- package/src/Table/BaseTable.tsx +51 -24
- package/src/Table/Table.doc.mjs +12 -0
- package/src/Table/Table.test.tsx +29 -30
- package/src/Table/Table.tsx +22 -1
- package/src/Table/TableRow.tsx +37 -7
- package/src/Table/index.ts +3 -0
- package/src/Table/plugins/stickyColumns/index.ts +4 -0
- package/src/Table/plugins/stickyColumns/useTableStickyColumns.test.tsx +163 -0
- package/src/Table/plugins/stickyColumns/useTableStickyColumns.tsx +407 -0
- package/src/Table/types.ts +101 -4
- package/src/Table/useBaseTablePlugins.ts +1 -0
- package/src/Table/useTableColumnResize.doc.mjs +96 -0
- package/src/Table/useTableStickyColumns.doc.mjs +59 -0
- package/src/Text/Heading.doc.mjs +15 -2
- package/src/Text/Text.doc.mjs +7 -1
- package/src/Text/text.stylex.ts +2 -2
- package/src/Timestamp/Timestamp.doc.mjs +9 -0
- package/src/Timestamp/Timestamp.test.tsx +44 -18
- package/src/Timestamp/Timestamp.tsx +30 -10
- package/src/ToggleButton/ToggleButton.doc.mjs +9 -3
- package/src/ToggleButton/ToggleButton.test.tsx +39 -18
- package/src/ToggleButton/ToggleButton.tsx +46 -83
- package/src/Tokenizer/Tokenizer.tsx +4 -1
- package/src/Toolbar/Toolbar.doc.mjs +65 -5
- package/src/Tooltip/Tooltip.test.tsx +13 -0
- package/src/Tooltip/useTooltip.tsx +2 -3
- package/src/hooks/useImageMode.test.ts +133 -0
- package/src/theme/derivedVarRegistry.test.ts +4 -0
- package/src/theme/generateThemeRules.test.ts +7 -8
- package/src/theme/generateThemeRules.ts +1 -1
- package/src/theme/syntax/SyntaxTheme.tsx +1 -1
- package/src/theme/syntax/defineSyntaxTheme.ts +20 -11
- package/src/theme/syntax/presets.ts +5 -2
- package/src/theme/syntax/tokens.ts +1 -1
- 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
|
+
};
|
package/src/Text/Heading.doc.mjs
CHANGED
|
@@ -32,7 +32,7 @@ export const docs = {
|
|
|
32
32
|
},
|
|
33
33
|
{
|
|
34
34
|
name: 'color',
|
|
35
|
-
type: "'primary' | 'secondary' | 'disabled' | 'placeholder' | '
|
|
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' | '
|
|
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.',
|
package/src/Text/Text.doc.mjs
CHANGED
|
@@ -40,7 +40,7 @@ export const docs = {
|
|
|
40
40
|
},
|
|
41
41
|
{
|
|
42
42
|
name: 'color',
|
|
43
|
-
type: "'primary' | 'secondary' | 'disabled' | 'placeholder' | '
|
|
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',
|
package/src/Text/text.stylex.ts
CHANGED
|
@@ -35,8 +35,8 @@ export const colorStyles = stylex.create({
|
|
|
35
35
|
placeholder: {
|
|
36
36
|
color: colorVars['--color-text-secondary'],
|
|
37
37
|
},
|
|
38
|
-
|
|
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 "
|
|
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('
|
|
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('
|
|
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", "
|
|
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
|
|
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
|
|
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
|
|
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
|
|
221
|
-
// button is
|
|
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('
|
|
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.
|
|
294
|
-
//
|
|
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
|
// =============================================================================
|