@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.
Files changed (118) hide show
  1. package/dist/Button/Button.d.ts.map +1 -1
  2. package/dist/Button/Button.js +28 -13
  3. package/dist/Chat/ChatLayout.d.ts +0 -19
  4. package/dist/Chat/ChatLayout.d.ts.map +1 -1
  5. package/dist/Chat/ChatLayout.js +0 -19
  6. package/dist/Chat/ChatLayoutScrollButton.d.ts.map +1 -1
  7. package/dist/Chat/ChatLayoutScrollButton.js +34 -44
  8. package/dist/Citation/Citation.d.ts +1 -1
  9. package/dist/Citation/Citation.d.ts.map +1 -1
  10. package/dist/Citation/Citation.js +2 -3
  11. package/dist/CodeBlock/highlightRanges.d.ts.map +1 -1
  12. package/dist/CodeBlock/highlightRanges.js +3 -14
  13. package/dist/Icon/Icon.d.ts +31 -6
  14. package/dist/Icon/Icon.d.ts.map +1 -1
  15. package/dist/Icon/Icon.js +71 -8
  16. package/dist/Link/useLinkComponent.d.ts +15 -5
  17. package/dist/Link/useLinkComponent.d.ts.map +1 -1
  18. package/dist/Link/useLinkComponent.js +51 -34
  19. package/dist/Markdown/Markdown.js +2 -2
  20. package/dist/Markdown/url.d.ts +0 -2
  21. package/dist/Markdown/url.d.ts.map +1 -1
  22. package/dist/Markdown/url.js +2 -8
  23. package/dist/PowerSearch/PowerSearchValueEditor.d.ts.map +1 -1
  24. package/dist/PowerSearch/PowerSearchValueEditor.js +0 -5
  25. package/dist/TopNav/TopNavMenu.d.ts +1 -1
  26. package/dist/TopNav/TopNavMenu.d.ts.map +1 -1
  27. package/dist/TopNav/TopNavMenu.js +2 -3
  28. package/dist/astryx.css +4 -2
  29. package/dist/hooks/useClickableContainer.d.ts +1 -10
  30. package/dist/hooks/useClickableContainer.d.ts.map +1 -1
  31. package/dist/hooks/useClickableContainer.js +4 -15
  32. package/dist/index.d.ts +0 -1
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +0 -3
  35. package/dist/theme/generateThemeRules.d.ts +7 -13
  36. package/dist/theme/generateThemeRules.d.ts.map +1 -1
  37. package/dist/theme/generateThemeRules.js +35 -131
  38. package/package.json +4 -9
  39. package/src/Button/Button.doc.mjs +5 -5
  40. package/src/Button/Button.tsx +13 -15
  41. package/src/Chat/ChatLayout.doc.mjs +4 -12
  42. package/src/Chat/ChatLayout.test.tsx +0 -28
  43. package/src/Chat/ChatLayout.tsx +0 -19
  44. package/src/Chat/ChatLayoutScrollButton.test.tsx +0 -31
  45. package/src/Chat/ChatLayoutScrollButton.tsx +14 -31
  46. package/src/Citation/Citation.doc.mjs +2 -2
  47. package/src/Citation/Citation.test.tsx +0 -17
  48. package/src/Citation/Citation.tsx +2 -4
  49. package/src/ClickableCard/ClickableCard.doc.mjs +2 -2
  50. package/src/CodeBlock/highlightRanges.test.ts +1 -143
  51. package/src/CodeBlock/highlightRanges.ts +5 -15
  52. package/src/Icon/Icon.doc.mjs +5 -7
  53. package/src/Icon/Icon.spec.md +40 -81
  54. package/src/Icon/Icon.test.tsx +3 -6
  55. package/src/Icon/Icon.tsx +68 -20
  56. package/src/IconButton/IconButton.doc.mjs +3 -3
  57. package/src/IconButton/IconButton.test.tsx +0 -40
  58. package/src/Item/Item.doc.mjs +2 -2
  59. package/src/Link/Link.doc.mjs +4 -6
  60. package/src/Link/LinkProvider.doc.mjs +2 -2
  61. package/src/Link/useLinkComponent.test.tsx +0 -333
  62. package/src/Link/useLinkComponent.ts +57 -29
  63. package/src/List/ListItem.doc.mjs +2 -2
  64. package/src/Markdown/Markdown.doc.mjs +2 -2
  65. package/src/Markdown/Markdown.spec.md +15 -19
  66. package/src/Markdown/Markdown.test.tsx +0 -52
  67. package/src/Markdown/Markdown.tsx +2 -2
  68. package/src/Markdown/parser.test.ts +0 -20
  69. package/src/Markdown/url.ts +3 -9
  70. package/src/PowerSearch/PowerSearchValueEditor.test.tsx +0 -51
  71. package/src/PowerSearch/PowerSearchValueEditor.tsx +0 -4
  72. package/src/Token/Token.doc.mjs +2 -2
  73. package/src/TopNav/TopNavMenu.doc.mjs +2 -2
  74. package/src/TopNav/TopNavMenu.test.tsx +0 -30
  75. package/src/TopNav/TopNavMenu.tsx +2 -7
  76. package/src/hooks/useClickableContainer.ts +4 -20
  77. package/src/index.ts +0 -3
  78. package/src/tailwind-theme.css +6 -0
  79. package/src/theme/Theme.test.tsx +0 -32
  80. package/src/theme/generateThemeRules.test.ts +2 -470
  81. package/src/theme/generateThemeRules.ts +51 -219
  82. package/src/theme/tokenValueCompat.test.ts +1 -4
  83. package/dist/Icon/IconDefaultSizeContext.d.ts +0 -4
  84. package/dist/Icon/IconDefaultSizeContext.d.ts.map +0 -1
  85. package/dist/Icon/IconDefaultSizeContext.js +0 -18
  86. package/dist/Icon/IconSize.stylex.d.ts +0 -65
  87. package/dist/Icon/IconSize.stylex.d.ts.map +0 -1
  88. package/dist/Icon/IconSize.stylex.js +0 -75
  89. package/dist/Timer/Timer.d.ts +0 -49
  90. package/dist/Timer/Timer.d.ts.map +0 -1
  91. package/dist/Timer/Timer.js +0 -158
  92. package/dist/Timer/index.d.ts +0 -9
  93. package/dist/Timer/index.d.ts.map +0 -1
  94. package/dist/Timer/index.js +0 -10
  95. package/dist/theme/declarationBoundary.d.ts +0 -17
  96. package/dist/theme/declarationBoundary.d.ts.map +0 -1
  97. package/dist/theme/declarationBoundary.js +0 -343
  98. package/dist/utils/safeUrl.d.ts +0 -11
  99. package/dist/utils/safeUrl.d.ts.map +0 -1
  100. package/dist/utils/safeUrl.js +0 -78
  101. package/src/Chat/ChatLayout.spec.md +0 -271
  102. package/src/Chat/ChatLayoutScrollButton.spec.md +0 -338
  103. package/src/Chat/__tests__/ChatLayoutScrollButton.a11y.chromium.spec.ts +0 -514
  104. package/src/Chat/__tests__/ChatLayoutScrollButtonSurface.a11y.chromium.spec.ts +0 -740
  105. package/src/Icon/IconDefaultSizeContext.ts +0 -23
  106. package/src/Icon/IconSize.stylex.ts +0 -70
  107. package/src/Link/__tests__/Link.navigation.a11y.chromium.spec.ts +0 -261
  108. package/src/Markdown/Markdown.renderBoundary.test.tsx +0 -72
  109. package/src/Timer/Timer.doc.mjs +0 -183
  110. package/src/Timer/Timer.spec.md +0 -215
  111. package/src/Timer/Timer.test.tsx +0 -302
  112. package/src/Timer/Timer.tsx +0 -258
  113. package/src/Timer/index.ts +0 -11
  114. package/src/hooks/useClickableContainer.test.tsx +0 -236
  115. package/src/theme/declarationBoundary.test.ts +0 -126
  116. package/src/theme/declarationBoundary.ts +0 -347
  117. package/src/utils/safeUrl.test.ts +0 -88
  118. package/src/utils/safeUrl.ts +0 -85
@@ -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';
@@ -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
- });