@astryxdesign/core 0.6.3-canary.f22695a → 0.6.3
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/dist/Button/Button.d.ts.map +1 -1
- package/dist/Button/Button.js +28 -13
- package/dist/Chat/ChatLayout.d.ts +0 -19
- package/dist/Chat/ChatLayout.d.ts.map +1 -1
- package/dist/Chat/ChatLayout.js +0 -19
- package/dist/Chat/ChatLayoutScrollButton.d.ts.map +1 -1
- package/dist/Chat/ChatLayoutScrollButton.js +34 -44
- package/dist/Citation/Citation.d.ts +1 -1
- package/dist/Citation/Citation.d.ts.map +1 -1
- package/dist/Citation/Citation.js +2 -3
- package/dist/CodeBlock/highlightRanges.d.ts.map +1 -1
- package/dist/CodeBlock/highlightRanges.js +3 -14
- package/dist/Icon/Icon.d.ts +31 -6
- package/dist/Icon/Icon.d.ts.map +1 -1
- package/dist/Icon/Icon.js +71 -8
- package/dist/Link/useLinkComponent.d.ts +15 -5
- package/dist/Link/useLinkComponent.d.ts.map +1 -1
- package/dist/Link/useLinkComponent.js +51 -34
- package/dist/Markdown/Markdown.js +2 -2
- package/dist/Markdown/url.d.ts +0 -2
- package/dist/Markdown/url.d.ts.map +1 -1
- package/dist/Markdown/url.js +2 -8
- package/dist/PowerSearch/PowerSearchValueEditor.d.ts.map +1 -1
- package/dist/PowerSearch/PowerSearchValueEditor.js +0 -5
- package/dist/TopNav/TopNavMenu.d.ts +1 -1
- package/dist/TopNav/TopNavMenu.d.ts.map +1 -1
- package/dist/TopNav/TopNavMenu.js +2 -3
- package/dist/astryx.css +4 -2
- package/dist/hooks/useClickableContainer.d.ts +1 -10
- package/dist/hooks/useClickableContainer.d.ts.map +1 -1
- package/dist/hooks/useClickableContainer.js +4 -15
- package/dist/index.d.ts +0 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +0 -3
- package/dist/theme/generateThemeRules.d.ts +7 -13
- package/dist/theme/generateThemeRules.d.ts.map +1 -1
- package/dist/theme/generateThemeRules.js +35 -131
- package/package.json +4 -9
- package/src/Button/Button.doc.mjs +5 -5
- package/src/Button/Button.tsx +13 -15
- package/src/Chat/ChatLayout.doc.mjs +4 -12
- package/src/Chat/ChatLayout.test.tsx +0 -28
- package/src/Chat/ChatLayout.tsx +0 -19
- package/src/Chat/ChatLayoutScrollButton.test.tsx +0 -31
- package/src/Chat/ChatLayoutScrollButton.tsx +14 -31
- package/src/Citation/Citation.doc.mjs +2 -2
- package/src/Citation/Citation.test.tsx +0 -17
- package/src/Citation/Citation.tsx +2 -4
- package/src/ClickableCard/ClickableCard.doc.mjs +2 -2
- package/src/CodeBlock/highlightRanges.test.ts +1 -143
- package/src/CodeBlock/highlightRanges.ts +5 -15
- package/src/Icon/Icon.doc.mjs +5 -7
- package/src/Icon/Icon.spec.md +40 -81
- package/src/Icon/Icon.test.tsx +3 -6
- package/src/Icon/Icon.tsx +68 -20
- package/src/IconButton/IconButton.doc.mjs +3 -3
- package/src/IconButton/IconButton.test.tsx +0 -40
- package/src/Item/Item.doc.mjs +2 -2
- package/src/Link/Link.doc.mjs +4 -6
- package/src/Link/LinkProvider.doc.mjs +2 -2
- package/src/Link/useLinkComponent.test.tsx +0 -333
- package/src/Link/useLinkComponent.ts +57 -29
- package/src/List/ListItem.doc.mjs +2 -2
- package/src/Markdown/Markdown.doc.mjs +2 -2
- package/src/Markdown/Markdown.spec.md +15 -19
- package/src/Markdown/Markdown.test.tsx +0 -52
- package/src/Markdown/Markdown.tsx +2 -2
- package/src/Markdown/parser.test.ts +0 -20
- package/src/Markdown/url.ts +3 -9
- package/src/PowerSearch/PowerSearchValueEditor.test.tsx +0 -51
- package/src/PowerSearch/PowerSearchValueEditor.tsx +0 -4
- package/src/Token/Token.doc.mjs +2 -2
- package/src/TopNav/TopNavMenu.doc.mjs +2 -2
- package/src/TopNav/TopNavMenu.test.tsx +0 -30
- package/src/TopNav/TopNavMenu.tsx +2 -7
- package/src/hooks/useClickableContainer.ts +4 -20
- package/src/index.ts +0 -3
- package/src/tailwind-theme.css +6 -0
- package/src/theme/Theme.test.tsx +0 -32
- package/src/theme/generateThemeRules.test.ts +2 -470
- package/src/theme/generateThemeRules.ts +51 -219
- package/src/theme/tokenValueCompat.test.ts +1 -4
- package/dist/Icon/IconDefaultSizeContext.d.ts +0 -4
- package/dist/Icon/IconDefaultSizeContext.d.ts.map +0 -1
- package/dist/Icon/IconDefaultSizeContext.js +0 -18
- package/dist/Icon/IconSize.stylex.d.ts +0 -65
- package/dist/Icon/IconSize.stylex.d.ts.map +0 -1
- package/dist/Icon/IconSize.stylex.js +0 -75
- package/dist/Timer/Timer.d.ts +0 -49
- package/dist/Timer/Timer.d.ts.map +0 -1
- package/dist/Timer/Timer.js +0 -158
- package/dist/Timer/index.d.ts +0 -9
- package/dist/Timer/index.d.ts.map +0 -1
- package/dist/Timer/index.js +0 -10
- package/dist/theme/declarationBoundary.d.ts +0 -17
- package/dist/theme/declarationBoundary.d.ts.map +0 -1
- package/dist/theme/declarationBoundary.js +0 -343
- package/dist/utils/safeUrl.d.ts +0 -11
- package/dist/utils/safeUrl.d.ts.map +0 -1
- package/dist/utils/safeUrl.js +0 -78
- package/src/Chat/ChatLayout.spec.md +0 -271
- package/src/Chat/ChatLayoutScrollButton.spec.md +0 -338
- package/src/Chat/__tests__/ChatLayoutScrollButton.a11y.chromium.spec.ts +0 -514
- package/src/Chat/__tests__/ChatLayoutScrollButtonSurface.a11y.chromium.spec.ts +0 -740
- package/src/Icon/IconDefaultSizeContext.ts +0 -23
- package/src/Icon/IconSize.stylex.ts +0 -70
- package/src/Link/__tests__/Link.navigation.a11y.chromium.spec.ts +0 -261
- package/src/Markdown/Markdown.renderBoundary.test.tsx +0 -72
- package/src/Timer/Timer.doc.mjs +0 -183
- package/src/Timer/Timer.spec.md +0 -215
- package/src/Timer/Timer.test.tsx +0 -302
- package/src/Timer/Timer.tsx +0 -258
- package/src/Timer/index.ts +0 -11
- package/src/hooks/useClickableContainer.test.tsx +0 -236
- package/src/theme/declarationBoundary.test.ts +0 -126
- package/src/theme/declarationBoundary.ts +0 -347
- package/src/utils/safeUrl.test.ts +0 -88
- package/src/utils/safeUrl.ts +0 -85
package/src/Timer/Timer.tsx
DELETED
|
@@ -1,258 +0,0 @@
|
|
|
1
|
-
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
-
|
|
3
|
-
'use client';
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* @file Timer.tsx
|
|
7
|
-
* @input Uses an optional start time, standardized format, Timestamp typography, BaseProps, and React ref
|
|
8
|
-
* @output Exports Timer, TimerProps, and TimerFormat with non-rendering elapsed-time updates
|
|
9
|
-
* @position Core content primitive for elapsed duration in active operations
|
|
10
|
-
*
|
|
11
|
-
* SYNC: When modified, update these files to stay in sync:
|
|
12
|
-
* - /packages/core/src/Timer/Timer.spec.md
|
|
13
|
-
* - /packages/core/src/Timer/Timer.doc.mjs
|
|
14
|
-
* - /packages/core/src/Timer/Timer.test.tsx
|
|
15
|
-
* - /packages/core/src/Timer/index.ts
|
|
16
|
-
* - /apps/storybook/stories/Timer.stories.tsx
|
|
17
|
-
* - /packages/cli/assets/templates/blocks/components/Timer/
|
|
18
|
-
*/
|
|
19
|
-
|
|
20
|
-
import {useEffect, useRef, useState} from 'react';
|
|
21
|
-
import * as stylex from '@stylexjs/stylex';
|
|
22
|
-
import type {BaseProps} from '../BaseProps';
|
|
23
|
-
import {useMergedRefs} from '../hooks/useMergedRefs';
|
|
24
|
-
import {Text} from '../Text';
|
|
25
|
-
import type {TextColor, TextSize, TextType, TextWeight} from '../theme/types';
|
|
26
|
-
import {mergeProps} from '../utils';
|
|
27
|
-
import {themeProps} from '../utils/themeProps';
|
|
28
|
-
|
|
29
|
-
const ONE_SECOND_MS = 1000;
|
|
30
|
-
const ONE_MINUTE_MS = 60 * ONE_SECOND_MS;
|
|
31
|
-
const ONE_HOUR_SECONDS = 60 * 60;
|
|
32
|
-
const MAX_TIMEOUT_MS = 2_147_483_647;
|
|
33
|
-
|
|
34
|
-
const styles = stylex.create({
|
|
35
|
-
time: {
|
|
36
|
-
color: 'inherit',
|
|
37
|
-
display: 'inline',
|
|
38
|
-
fontFamily: 'inherit',
|
|
39
|
-
fontSize: 'inherit',
|
|
40
|
-
fontStyle: 'normal',
|
|
41
|
-
fontVariantNumeric: 'tabular-nums',
|
|
42
|
-
fontWeight: 'inherit',
|
|
43
|
-
lineHeight: 'inherit',
|
|
44
|
-
},
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
export type TimerFormat = 'elapsed' | 'clock';
|
|
48
|
-
|
|
49
|
-
type TimerPresentation = {
|
|
50
|
-
dateTime: string;
|
|
51
|
-
text: string;
|
|
52
|
-
};
|
|
53
|
-
|
|
54
|
-
function pad(value: number): string {
|
|
55
|
-
return String(value).padStart(2, '0');
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function resolveFormat(format: TimerFormat): TimerFormat {
|
|
59
|
-
return format === 'clock' ? 'clock' : 'elapsed';
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
function getElapsedMilliseconds(now: number, startTime: number): number {
|
|
63
|
-
return Math.max(0, now - startTime);
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
function getPresentation(
|
|
67
|
-
elapsedMilliseconds: number,
|
|
68
|
-
format: TimerFormat,
|
|
69
|
-
): TimerPresentation {
|
|
70
|
-
const elapsedSeconds = Math.floor(elapsedMilliseconds / ONE_SECOND_MS);
|
|
71
|
-
|
|
72
|
-
if (format === 'clock') {
|
|
73
|
-
const hours = Math.floor(elapsedSeconds / ONE_HOUR_SECONDS);
|
|
74
|
-
const minutes = Math.floor((elapsedSeconds % ONE_HOUR_SECONDS) / 60);
|
|
75
|
-
const seconds = elapsedSeconds % 60;
|
|
76
|
-
return {
|
|
77
|
-
dateTime: `PT${elapsedSeconds}S`,
|
|
78
|
-
text:
|
|
79
|
-
hours > 0
|
|
80
|
-
? `${String(hours)}:${pad(minutes)}:${pad(seconds)}`
|
|
81
|
-
: `${String(minutes)}:${pad(seconds)}`,
|
|
82
|
-
};
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
if (elapsedSeconds < 60) {
|
|
86
|
-
return {
|
|
87
|
-
dateTime: `PT${elapsedSeconds}S`,
|
|
88
|
-
text: `${String(elapsedSeconds)}s`,
|
|
89
|
-
};
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
const totalMinutes = Math.floor(elapsedSeconds / 60);
|
|
93
|
-
if (totalMinutes < 60) {
|
|
94
|
-
return {
|
|
95
|
-
dateTime: `PT${elapsedSeconds}S`,
|
|
96
|
-
text: `${String(totalMinutes)}m ${pad(elapsedSeconds % 60)}s`,
|
|
97
|
-
};
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
const representedSeconds = totalMinutes * 60;
|
|
101
|
-
return {
|
|
102
|
-
dateTime: `PT${representedSeconds}S`,
|
|
103
|
-
text: `${String(Math.floor(totalMinutes / 60))}h ${pad(totalMinutes % 60)}m`,
|
|
104
|
-
};
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
function getMillisecondsUntilNextChange(
|
|
108
|
-
now: number,
|
|
109
|
-
startTime: number,
|
|
110
|
-
elapsedMilliseconds: number,
|
|
111
|
-
format: TimerFormat,
|
|
112
|
-
): number {
|
|
113
|
-
if (now < startTime) {
|
|
114
|
-
return Math.min(startTime - now + ONE_SECOND_MS, MAX_TIMEOUT_MS);
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
const precision =
|
|
118
|
-
format === 'elapsed' &&
|
|
119
|
-
elapsedMilliseconds >= ONE_HOUR_SECONDS * ONE_SECOND_MS
|
|
120
|
-
? ONE_MINUTE_MS
|
|
121
|
-
: ONE_SECOND_MS;
|
|
122
|
-
return precision - (elapsedMilliseconds % precision);
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
export interface TimerProps extends Omit<
|
|
126
|
-
BaseProps<HTMLTimeElement>,
|
|
127
|
-
'dateTime'
|
|
128
|
-
> {
|
|
129
|
-
/** Ref forwarded to the rendered `<time>` element. */
|
|
130
|
-
ref?: React.Ref<HTMLTimeElement>;
|
|
131
|
-
/**
|
|
132
|
-
* Unix time in milliseconds when the measured operation began. Omit it to
|
|
133
|
-
* start counting from this Timer's mount.
|
|
134
|
-
*/
|
|
135
|
-
startTime?: number;
|
|
136
|
-
/**
|
|
137
|
-
* Standard duration representation.
|
|
138
|
-
* @default 'elapsed'
|
|
139
|
-
*/
|
|
140
|
-
format?: TimerFormat;
|
|
141
|
-
/**
|
|
142
|
-
* Semantic text type. Matches Timestamp typography behavior.
|
|
143
|
-
* @default 'supporting'
|
|
144
|
-
*/
|
|
145
|
-
type?: TextType;
|
|
146
|
-
/** Explicit font size override. Overrides the size from `type`. */
|
|
147
|
-
size?: TextSize;
|
|
148
|
-
/**
|
|
149
|
-
* Text color.
|
|
150
|
-
* @default 'secondary'
|
|
151
|
-
*/
|
|
152
|
-
color?: TextColor;
|
|
153
|
-
/** Font weight override. */
|
|
154
|
-
weight?: TextWeight;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
/**
|
|
158
|
-
* Displays a standardized elapsed duration without scheduling React tick renders.
|
|
159
|
-
*
|
|
160
|
-
* Timer writes changing text and its ISO 8601 duration directly to the owned
|
|
161
|
-
* `<time>` node. Use `elapsed` for compact duration text or `clock` for a
|
|
162
|
-
* stopwatch-like reading.
|
|
163
|
-
*
|
|
164
|
-
* @example
|
|
165
|
-
* ```
|
|
166
|
-
* <Timer />
|
|
167
|
-
* <Timer format="clock" />
|
|
168
|
-
* ```
|
|
169
|
-
*/
|
|
170
|
-
export function Timer({
|
|
171
|
-
startTime,
|
|
172
|
-
format = 'elapsed',
|
|
173
|
-
type = 'supporting',
|
|
174
|
-
size,
|
|
175
|
-
color = 'secondary',
|
|
176
|
-
weight,
|
|
177
|
-
ref,
|
|
178
|
-
xstyle,
|
|
179
|
-
className,
|
|
180
|
-
style,
|
|
181
|
-
...rest
|
|
182
|
-
}: TimerProps) {
|
|
183
|
-
const [mountTime] = useState(() => Date.now());
|
|
184
|
-
const timerRef = useRef<HTMLTimeElement>(null);
|
|
185
|
-
const mergedRef = useMergedRefs(ref, timerRef);
|
|
186
|
-
const resolvedFormat = resolveFormat(format);
|
|
187
|
-
const initialPresentation = getPresentation(0, resolvedFormat);
|
|
188
|
-
|
|
189
|
-
useEffect(() => {
|
|
190
|
-
const resolvedStartTime =
|
|
191
|
-
startTime !== undefined && Number.isFinite(startTime)
|
|
192
|
-
? startTime
|
|
193
|
-
: mountTime;
|
|
194
|
-
let timeoutID: ReturnType<typeof setTimeout> | undefined;
|
|
195
|
-
let previousDateTime: string | undefined;
|
|
196
|
-
let previousText: string | undefined;
|
|
197
|
-
|
|
198
|
-
const tick = () => {
|
|
199
|
-
const now = Date.now();
|
|
200
|
-
const elapsedMilliseconds = getElapsedMilliseconds(
|
|
201
|
-
now,
|
|
202
|
-
resolvedStartTime,
|
|
203
|
-
);
|
|
204
|
-
const presentation = getPresentation(elapsedMilliseconds, resolvedFormat);
|
|
205
|
-
const node = timerRef.current;
|
|
206
|
-
|
|
207
|
-
if (node != null) {
|
|
208
|
-
if (presentation.text !== previousText) {
|
|
209
|
-
node.textContent = presentation.text;
|
|
210
|
-
previousText = presentation.text;
|
|
211
|
-
}
|
|
212
|
-
if (presentation.dateTime !== previousDateTime) {
|
|
213
|
-
node.dateTime = presentation.dateTime;
|
|
214
|
-
previousDateTime = presentation.dateTime;
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
timeoutID = setTimeout(
|
|
219
|
-
tick,
|
|
220
|
-
getMillisecondsUntilNextChange(
|
|
221
|
-
now,
|
|
222
|
-
resolvedStartTime,
|
|
223
|
-
elapsedMilliseconds,
|
|
224
|
-
resolvedFormat,
|
|
225
|
-
),
|
|
226
|
-
);
|
|
227
|
-
};
|
|
228
|
-
|
|
229
|
-
tick();
|
|
230
|
-
return () => {
|
|
231
|
-
if (timeoutID !== undefined) {
|
|
232
|
-
clearTimeout(timeoutID);
|
|
233
|
-
}
|
|
234
|
-
};
|
|
235
|
-
}, [mountTime, resolvedFormat, startTime]);
|
|
236
|
-
|
|
237
|
-
const timerProps = mergeProps(themeProps('timer'), {className, style});
|
|
238
|
-
|
|
239
|
-
return (
|
|
240
|
-
<Text
|
|
241
|
-
type={type}
|
|
242
|
-
size={size}
|
|
243
|
-
color={color}
|
|
244
|
-
weight={weight}
|
|
245
|
-
xstyle={xstyle}
|
|
246
|
-
{...timerProps}>
|
|
247
|
-
<time
|
|
248
|
-
{...rest}
|
|
249
|
-
ref={mergedRef}
|
|
250
|
-
dateTime="PT0S"
|
|
251
|
-
{...stylex.props(styles.time)}>
|
|
252
|
-
{initialPresentation.text}
|
|
253
|
-
</time>
|
|
254
|
-
</Text>
|
|
255
|
-
);
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
Timer.displayName = 'Timer';
|
package/src/Timer/index.ts
DELETED
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* @file index.ts
|
|
5
|
-
* @input Imports Timer, TimerProps, and TimerFormat
|
|
6
|
-
* @output Public Timer component barrel
|
|
7
|
-
* @position Component subpath entry point for @astryxdesign/core/Timer
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
export {Timer} from './Timer';
|
|
11
|
-
export type {TimerFormat, TimerProps} from './Timer';
|
|
@@ -1,236 +0,0 @@
|
|
|
1
|
-
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* @file useClickableContainer.test.tsx
|
|
5
|
-
* @input Uses vitest, @testing-library/react, useClickableContainer
|
|
6
|
-
* @output Unit tests for the hook's navigation paths
|
|
7
|
-
* @position Testing; pins the shared navigation-destination rule on every
|
|
8
|
-
* activation method the hook owns — plain click (location.href), new tab
|
|
9
|
-
* (target="_blank"), Cmd/Ctrl-click, middle-click, and the delegated click
|
|
10
|
-
* on the interactive ref. None of these pass through React DOM's own href
|
|
11
|
-
* vetting, so each one has a positive and a blocked case here.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
|
15
|
-
import {useRef} from 'react';
|
|
16
|
-
import {fireEvent, render, screen} from '@testing-library/react';
|
|
17
|
-
import {useClickableContainer} from './useClickableContainer';
|
|
18
|
-
|
|
19
|
-
// The scheme the rule exists to stop (kept in consts so the JSX below stays
|
|
20
|
-
// lint-clean — the tests are ABOUT these URLs never navigating).
|
|
21
|
-
const scriptUrl = 'javascript:alert(1)';
|
|
22
|
-
// The same scheme hidden behind a control character a browser ignores.
|
|
23
|
-
const hiddenScriptUrl = 'java\nscript:alert(1)';
|
|
24
|
-
|
|
25
|
-
function Card({href, target}: {href: string; target?: string}) {
|
|
26
|
-
const containerRef = useRef<HTMLDivElement>(null);
|
|
27
|
-
const {onClick, onMouseUp} = useClickableContainer({
|
|
28
|
-
containerRef,
|
|
29
|
-
href,
|
|
30
|
-
target,
|
|
31
|
-
});
|
|
32
|
-
return (
|
|
33
|
-
<div
|
|
34
|
-
ref={containerRef}
|
|
35
|
-
data-testid="card"
|
|
36
|
-
onClick={onClick}
|
|
37
|
-
onMouseUp={onMouseUp}>
|
|
38
|
-
content
|
|
39
|
-
</div>
|
|
40
|
-
);
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* Card that delegates plain clicks to an inner link (the ClickableCard /
|
|
45
|
-
* Item shape). The inner anchor's own click is spied on so the test observes
|
|
46
|
-
* whether the hook activated it.
|
|
47
|
-
*/
|
|
48
|
-
function DelegatingCard({
|
|
49
|
-
href,
|
|
50
|
-
onAnchorClick,
|
|
51
|
-
}: {
|
|
52
|
-
href: string;
|
|
53
|
-
onAnchorClick: () => void;
|
|
54
|
-
}) {
|
|
55
|
-
const containerRef = useRef<HTMLDivElement>(null);
|
|
56
|
-
const interactiveRef = useRef<HTMLAnchorElement>(null);
|
|
57
|
-
const {onClick, onMouseUp} = useClickableContainer({
|
|
58
|
-
containerRef,
|
|
59
|
-
interactiveRef,
|
|
60
|
-
href,
|
|
61
|
-
});
|
|
62
|
-
return (
|
|
63
|
-
<div
|
|
64
|
-
ref={containerRef}
|
|
65
|
-
data-testid="card"
|
|
66
|
-
onClick={onClick}
|
|
67
|
-
onMouseUp={onMouseUp}>
|
|
68
|
-
content
|
|
69
|
-
<a
|
|
70
|
-
ref={interactiveRef}
|
|
71
|
-
// The anchor is the delegate, not the subject: the tests below use a
|
|
72
|
-
// fixed inert href here so that only the hook's decision is observed.
|
|
73
|
-
href="#inner"
|
|
74
|
-
data-testid="inner-link"
|
|
75
|
-
onClick={e => {
|
|
76
|
-
e.preventDefault();
|
|
77
|
-
onAnchorClick();
|
|
78
|
-
}}>
|
|
79
|
-
inner
|
|
80
|
-
</a>
|
|
81
|
-
</div>
|
|
82
|
-
);
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
afterEach(() => {
|
|
86
|
-
vi.restoreAllMocks();
|
|
87
|
-
vi.unstubAllGlobals();
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
describe('useClickableContainer navigation — new tab (target="_blank")', () => {
|
|
91
|
-
it('opens ordinary hrefs in a new tab', () => {
|
|
92
|
-
const open = vi.spyOn(window, 'open').mockImplementation(() => null);
|
|
93
|
-
render(<Card href="https://example.com/docs" target="_blank" />);
|
|
94
|
-
fireEvent.click(screen.getByTestId('card'));
|
|
95
|
-
expect(open).toHaveBeenCalledWith(
|
|
96
|
-
'https://example.com/docs',
|
|
97
|
-
'_blank',
|
|
98
|
-
'noopener',
|
|
99
|
-
);
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
it('does not open a script-scheme href in a new tab', () => {
|
|
103
|
-
const open = vi.spyOn(window, 'open').mockImplementation(() => null);
|
|
104
|
-
// eslint-disable-next-line @eslint-react/dom-no-script-url -- the test proves this URL never navigates
|
|
105
|
-
render(<Card href={scriptUrl} target="_blank" />);
|
|
106
|
-
fireEvent.click(screen.getByTestId('card'));
|
|
107
|
-
expect(open).not.toHaveBeenCalled();
|
|
108
|
-
});
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
describe('useClickableContainer navigation — Cmd/Ctrl-click', () => {
|
|
112
|
-
it('opens ordinary hrefs in a new tab on Cmd-click', () => {
|
|
113
|
-
const open = vi.spyOn(window, 'open').mockImplementation(() => null);
|
|
114
|
-
render(<Card href="/docs" />);
|
|
115
|
-
fireEvent.click(screen.getByTestId('card'), {metaKey: true});
|
|
116
|
-
expect(open).toHaveBeenCalledWith('/docs', '_blank', 'noopener');
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
it('opens ordinary hrefs in a new tab on Ctrl-click', () => {
|
|
120
|
-
const open = vi.spyOn(window, 'open').mockImplementation(() => null);
|
|
121
|
-
render(<Card href="/docs" />);
|
|
122
|
-
fireEvent.click(screen.getByTestId('card'), {ctrlKey: true});
|
|
123
|
-
expect(open).toHaveBeenCalledWith('/docs', '_blank', 'noopener');
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
it('does not open a script-scheme href on Cmd-click', () => {
|
|
127
|
-
const open = vi.spyOn(window, 'open').mockImplementation(() => null);
|
|
128
|
-
// eslint-disable-next-line @eslint-react/dom-no-script-url -- the test proves this URL never navigates
|
|
129
|
-
render(<Card href={scriptUrl} />);
|
|
130
|
-
fireEvent.click(screen.getByTestId('card'), {metaKey: true});
|
|
131
|
-
expect(open).not.toHaveBeenCalled();
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
it('does not open a script-scheme href on Ctrl-click', () => {
|
|
135
|
-
const open = vi.spyOn(window, 'open').mockImplementation(() => null);
|
|
136
|
-
// eslint-disable-next-line @eslint-react/dom-no-script-url -- the test proves this URL never navigates
|
|
137
|
-
render(<Card href={scriptUrl} />);
|
|
138
|
-
fireEvent.click(screen.getByTestId('card'), {ctrlKey: true});
|
|
139
|
-
expect(open).not.toHaveBeenCalled();
|
|
140
|
-
});
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
describe('useClickableContainer navigation — middle-click', () => {
|
|
144
|
-
it('opens ordinary hrefs on middle-click', () => {
|
|
145
|
-
const open = vi.spyOn(window, 'open').mockImplementation(() => null);
|
|
146
|
-
render(<Card href="/docs" />);
|
|
147
|
-
fireEvent.mouseUp(screen.getByTestId('card'), {button: 1});
|
|
148
|
-
expect(open).toHaveBeenCalledWith('/docs', '_blank', 'noopener');
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
it('does not open a script-scheme href on middle-click', () => {
|
|
152
|
-
const open = vi.spyOn(window, 'open').mockImplementation(() => null);
|
|
153
|
-
// eslint-disable-next-line @eslint-react/dom-no-script-url -- the test proves this URL never navigates
|
|
154
|
-
render(<Card href={scriptUrl} />);
|
|
155
|
-
fireEvent.mouseUp(screen.getByTestId('card'), {button: 1});
|
|
156
|
-
expect(open).not.toHaveBeenCalled();
|
|
157
|
-
});
|
|
158
|
-
});
|
|
159
|
-
|
|
160
|
-
describe('useClickableContainer navigation — plain click (location.href)', () => {
|
|
161
|
-
// jsdom does not navigate, so assigning `location.href` is a no-op there
|
|
162
|
-
// and would let a missing guard pass unnoticed. Stand in a plain object so
|
|
163
|
-
// the assignment is observable.
|
|
164
|
-
let location: {href: string};
|
|
165
|
-
|
|
166
|
-
beforeEach(() => {
|
|
167
|
-
location = {href: 'https://app.example/start'};
|
|
168
|
-
vi.stubGlobal('location', location);
|
|
169
|
-
});
|
|
170
|
-
|
|
171
|
-
it('assigns ordinary hrefs to location', () => {
|
|
172
|
-
render(<Card href="/docs" />);
|
|
173
|
-
fireEvent.click(screen.getByTestId('card'));
|
|
174
|
-
expect(location.href).toBe('/docs');
|
|
175
|
-
});
|
|
176
|
-
|
|
177
|
-
it('does not assign a script-scheme href to location', () => {
|
|
178
|
-
// eslint-disable-next-line @eslint-react/dom-no-script-url -- the test proves this URL never navigates
|
|
179
|
-
render(<Card href={scriptUrl} />);
|
|
180
|
-
fireEvent.click(screen.getByTestId('card'));
|
|
181
|
-
expect(location.href).toBe('https://app.example/start');
|
|
182
|
-
});
|
|
183
|
-
|
|
184
|
-
it('does not assign a control-character-hidden script scheme to location', () => {
|
|
185
|
-
// eslint-disable-next-line @eslint-react/dom-no-script-url -- the test proves this URL never navigates
|
|
186
|
-
render(<Card href={hiddenScriptUrl} />);
|
|
187
|
-
fireEvent.click(screen.getByTestId('card'));
|
|
188
|
-
expect(location.href).toBe('https://app.example/start');
|
|
189
|
-
});
|
|
190
|
-
});
|
|
191
|
-
|
|
192
|
-
describe('useClickableContainer navigation — delegated interactive ref', () => {
|
|
193
|
-
it('delegates ordinary hrefs to the inner link', () => {
|
|
194
|
-
const onAnchorClick = vi.fn();
|
|
195
|
-
render(<DelegatingCard href="/docs" onAnchorClick={onAnchorClick} />);
|
|
196
|
-
fireEvent.click(screen.getByTestId('card'));
|
|
197
|
-
expect(onAnchorClick).toHaveBeenCalledTimes(1);
|
|
198
|
-
});
|
|
199
|
-
|
|
200
|
-
it('does not delegate a script-scheme href to the inner link', () => {
|
|
201
|
-
const onAnchorClick = vi.fn();
|
|
202
|
-
// eslint-disable-next-line @eslint-react/dom-no-script-url -- the test proves this URL never navigates
|
|
203
|
-
render(<DelegatingCard href={scriptUrl} onAnchorClick={onAnchorClick} />);
|
|
204
|
-
fireEvent.click(screen.getByTestId('card'));
|
|
205
|
-
expect(onAnchorClick).not.toHaveBeenCalled();
|
|
206
|
-
});
|
|
207
|
-
|
|
208
|
-
it('a click on the inner link itself is left to the link', () => {
|
|
209
|
-
const onAnchorClick = vi.fn();
|
|
210
|
-
render(<DelegatingCard href="/docs" onAnchorClick={onAnchorClick} />);
|
|
211
|
-
fireEvent.click(screen.getByTestId('inner-link'));
|
|
212
|
-
// The anchor's own handler ran once; the hook did not proxy a second one.
|
|
213
|
-
expect(onAnchorClick).toHaveBeenCalledTimes(1);
|
|
214
|
-
});
|
|
215
|
-
});
|
|
216
|
-
|
|
217
|
-
describe('useClickableContainer navigation — blocked href keeps the rest', () => {
|
|
218
|
-
it('still fires the onClick handler for a blocked href', () => {
|
|
219
|
-
const onClick = vi.fn();
|
|
220
|
-
const open = vi.spyOn(window, 'open').mockImplementation(() => null);
|
|
221
|
-
function Surface() {
|
|
222
|
-
const containerRef = useRef<HTMLDivElement>(null);
|
|
223
|
-
const handlers = useClickableContainer({
|
|
224
|
-
containerRef,
|
|
225
|
-
href: scriptUrl,
|
|
226
|
-
target: '_blank',
|
|
227
|
-
onClick,
|
|
228
|
-
});
|
|
229
|
-
return <div ref={containerRef} data-testid="card" {...handlers} />;
|
|
230
|
-
}
|
|
231
|
-
render(<Surface />);
|
|
232
|
-
fireEvent.click(screen.getByTestId('card'));
|
|
233
|
-
expect(onClick).toHaveBeenCalledTimes(1);
|
|
234
|
-
expect(open).not.toHaveBeenCalled();
|
|
235
|
-
});
|
|
236
|
-
});
|
|
@@ -1,126 +0,0 @@
|
|
|
1
|
-
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* @file Tests for the declaration boundary scanner.
|
|
5
|
-
*
|
|
6
|
-
* Every "keeps" case is valid CSS a browser reads as one declaration; every
|
|
7
|
-
* "rejects" case would end the declaration or rule early, swallow what follows
|
|
8
|
-
* it, or be discarded by the browser as a bad token.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import {describe, it, expect} from 'vitest';
|
|
12
|
-
import {
|
|
13
|
-
checkDeclarationName,
|
|
14
|
-
checkDeclarationValue,
|
|
15
|
-
} from './declarationBoundary';
|
|
16
|
-
|
|
17
|
-
describe('checkDeclarationValue keeps valid CSS inside one declaration', () => {
|
|
18
|
-
it.each([
|
|
19
|
-
['plain color', '#FF00FF'],
|
|
20
|
-
['color function', 'color-mix(in oklch, #FF00FF 80%, white)'],
|
|
21
|
-
['gradient', 'linear-gradient(135deg, #FF00FF 0%, #00FFFF 100%)'],
|
|
22
|
-
['calc', 'calc(100% - 24px)'],
|
|
23
|
-
['light-dark', 'light-dark(#fff, #111)'],
|
|
24
|
-
['font stack', "'Inter Var', ui-sans-serif, system-ui"],
|
|
25
|
-
['important flag', 'red !important'],
|
|
26
|
-
[
|
|
27
|
-
'data URI, semicolon inside url()',
|
|
28
|
-
'url(data:image/png;base64,iVBORw0KGgo=)',
|
|
29
|
-
],
|
|
30
|
-
['uppercase URL()', 'URL(data:image/svg+xml;base64,PHN2Zz4=)'],
|
|
31
|
-
['uppercase URL with an inert brace', 'URL(data:text/plain,a{b)'],
|
|
32
|
-
['mixed-case Url() with spaces', 'Url( data:image/png;base64,iVBORw0= )'],
|
|
33
|
-
['quoted url with semicolon', 'url("data:image/svg+xml;utf8,<svg/>")'],
|
|
34
|
-
['quoted url with a quoted brace', "url('x.png?a={1;2}')"],
|
|
35
|
-
['url nested in image-set', 'image-set(url(a;b.png) 1x, url(c;d.png) 2x)'],
|
|
36
|
-
['escaped space in identifier', 'Gill\\ Sans, serif'],
|
|
37
|
-
['escaped semicolon in identifier', 'a\\;b'],
|
|
38
|
-
['hex escape', '\\31 23'],
|
|
39
|
-
['escaped quote inside a string', '"a\\" ; b"'],
|
|
40
|
-
['semicolon inside a double-quoted string', '"a;b"'],
|
|
41
|
-
['semicolon inside a single-quoted string', "'a;b'"],
|
|
42
|
-
['braces inside a string', '"} body { color: red }"'],
|
|
43
|
-
['closed comment', '/* brand */ red'],
|
|
44
|
-
['closed comment with a semicolon inside', 'red /* ; } */'],
|
|
45
|
-
['comment-like text inside a string', '"/* not a comment"'],
|
|
46
|
-
['balanced brackets in a grid line name', '[full-start] 1fr [full-end]'],
|
|
47
|
-
['semicolon nested in parentheses', 'var(--x, a;b)'],
|
|
48
|
-
['brace nested in parentheses', 'var(--x, {a})'],
|
|
49
|
-
['backslash before a newline is a delim', 'a\\\nb'],
|
|
50
|
-
['whitespace including tab and newline', 'a\t\nb'],
|
|
51
|
-
['CRLF string continuation', '"a\\\r\nb"'],
|
|
52
|
-
['CRLF after a hex escape in a string', '"\\61\r\nb"'],
|
|
53
|
-
['escaped url identifier', 'u\\72l(data:image/png;base64,a)'],
|
|
54
|
-
['null is preprocessed by CSS', 'red\u0000'],
|
|
55
|
-
['control character inside a string', '"a\u0001b"'],
|
|
56
|
-
['control character inside a closed comment', 'red /* \u0001 ; } */'],
|
|
57
|
-
['empty value', ''],
|
|
58
|
-
])('keeps %s', (_label, value) => {
|
|
59
|
-
expect(checkDeclarationValue(value)).toBeNull();
|
|
60
|
-
});
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
describe('checkDeclarationValue rejects true declaration and rule breaks', () => {
|
|
64
|
-
it.each([
|
|
65
|
-
['top-level semicolon', 'red; background: url(x)', ';'],
|
|
66
|
-
['top-level closing brace', 'red } body { color: blue', '}'],
|
|
67
|
-
['top-level opening brace', 'red { color: blue }', '{'],
|
|
68
|
-
['escaped quote is not a string opener', '\\"; background: red; "', ';'],
|
|
69
|
-
['unbalanced closing paren', 'red) ; body { color: blue', ')'],
|
|
70
|
-
['unbalanced closing bracket', 'red] ; x', ']'],
|
|
71
|
-
['mismatched closer', 'calc(1] ; x)', ']'],
|
|
72
|
-
['unclosed paren', 'calc(100% - 24px', '('],
|
|
73
|
-
['unclosed bracket', '[full-start', '['],
|
|
74
|
-
['unclosed string', '"open', 'string'],
|
|
75
|
-
['unclosed comment', 'red /* comment', 'comment'],
|
|
76
|
-
['unclosed url', 'url(x.png', 'url('],
|
|
77
|
-
['trailing backslash', 'red\\', 'backslash'],
|
|
78
|
-
['newline inside a string (bad string)', '"a\nb"', 'bad string'],
|
|
79
|
-
['whitespace inside an unquoted url (bad url)', 'url(a b)', 'bad url'],
|
|
80
|
-
['uppercase bad url', 'URL(a b)', 'bad url'],
|
|
81
|
-
['quote inside an unquoted url (bad url)', 'url(a"b)', 'bad url'],
|
|
82
|
-
['paren inside an unquoted url (bad url)', 'url(a(b)', 'bad url'],
|
|
83
|
-
[
|
|
84
|
-
'stray backslash inside an unquoted url (bad url)',
|
|
85
|
-
'url(a\\\nb)',
|
|
86
|
-
'bad url',
|
|
87
|
-
],
|
|
88
|
-
['control character inside an unquoted url', 'url(a\u0001b)', 'bad url'],
|
|
89
|
-
])('rejects %s', (_label, value, fragment) => {
|
|
90
|
-
const reason = checkDeclarationValue(value);
|
|
91
|
-
expect(reason).not.toBeNull();
|
|
92
|
-
expect(reason).toContain(fragment);
|
|
93
|
-
});
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
describe('checkDeclarationName', () => {
|
|
97
|
-
it.each([
|
|
98
|
-
'color',
|
|
99
|
-
'background-image',
|
|
100
|
-
'-webkit-line-clamp',
|
|
101
|
-
'-moz-osx-font-smoothing',
|
|
102
|
-
'--brand-accent',
|
|
103
|
-
'--_internal',
|
|
104
|
-
'--',
|
|
105
|
-
'--1',
|
|
106
|
-
'_private',
|
|
107
|
-
'caf\u00e9',
|
|
108
|
-
'a\\:b',
|
|
109
|
-
])('keeps %s', name => {
|
|
110
|
-
expect(checkDeclarationName(name)).toBeNull();
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
it.each([
|
|
114
|
-
'',
|
|
115
|
-
'1px',
|
|
116
|
-
'-1',
|
|
117
|
-
'color: red',
|
|
118
|
-
'--x:red} body{color:blue',
|
|
119
|
-
'color;',
|
|
120
|
-
'a b',
|
|
121
|
-
'a\\',
|
|
122
|
-
'background{',
|
|
123
|
-
])('rejects %j', name => {
|
|
124
|
-
expect(checkDeclarationName(name)).not.toBeNull();
|
|
125
|
-
});
|
|
126
|
-
});
|