@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
@@ -162,13 +162,10 @@ describe('Icon', () => {
162
162
  expect(icon).toHaveAttribute('aria-label', 'Home');
163
163
  });
164
164
 
165
- it('uses default color and standalone size when not specified', () => {
165
+ it('uses default color and size when not specified', () => {
166
166
  render(<Icon icon={TestIcon} data-testid="icon" />);
167
-
168
- const icon = screen.getByTestId('icon');
169
- expect(icon).toHaveAttribute('data-size', 'md');
170
- expect(getComputedStyle(icon).width).toBe('1.25rem');
171
- expect(getComputedStyle(icon).height).toBe('1.25rem');
167
+ // The component should render without errors with defaults
168
+ expect(screen.getByTestId('icon')).toBeInTheDocument();
172
169
  });
173
170
 
174
171
  it('applies aria-hidden by default in string (registry) mode', () => {
package/src/Icon/Icon.tsx CHANGED
@@ -31,14 +31,6 @@ import {getIcon} from './globalIconRegistry';
31
31
  import type {IconName, NamespacedIconName} from './globalIconRegistry';
32
32
  import {mergeProps} from '../utils';
33
33
  import {themeProps} from '../utils/themeProps';
34
- import {useIconSize} from './IconDefaultSizeContext';
35
- import {
36
- iconBoxSizeStyles,
37
- iconSizeStyles,
38
- type IconSize,
39
- } from './IconSize.stylex';
40
-
41
- export type {IconSize} from './IconSize.stylex';
42
34
 
43
35
  // =============================================================================
44
36
  // Styles
@@ -118,11 +110,75 @@ const colorStyles = stylex.create({
118
110
  },
119
111
  });
120
112
 
113
+ /**
114
+ * Size styles for direct SVG icon components.
115
+ * Uses width/height only — SVG components handle their own viewBox scaling.
116
+ *
117
+ * Sizes are expressed in `rem` (relative to the root font-size) so icons scale
118
+ * in step with text when the document font-size changes, matching the rest of
119
+ * the design system's rem-based type scale. Values are the px-equivalents at a
120
+ * 16px root: 12px → 0.75rem, 16px → 1rem, 20px → 1.25rem, 24px → 1.5rem.
121
+ */
122
+ const sizeStyles = stylex.create({
123
+ xsm: {
124
+ width: '0.75rem',
125
+ height: '0.75rem',
126
+ },
127
+ sm: {
128
+ width: '1rem',
129
+ height: '1rem',
130
+ },
131
+ md: {
132
+ width: '1.25rem',
133
+ height: '1.25rem',
134
+ },
135
+ lg: {
136
+ width: '1.5rem',
137
+ height: '1.5rem',
138
+ },
139
+ });
140
+
141
+ /**
142
+ * Size styles for string-based (registry) icons.
143
+ * Includes fontSize so that 1em-based icons from the registry scale correctly.
144
+ *
145
+ * Expressed in `rem` for the same reason as {@link sizeStyles} — icons track the
146
+ * root font-size instead of being locked to absolute pixels.
147
+ */
148
+ const spanSizeStyles = stylex.create({
149
+ /* eslint-disable @astryx/no-hardcoded-styles -- fontSize here sizes 1em-based
150
+ registry SVGs to the icon box; icons use their own 12/16/20/24 scale, not
151
+ the 14px-anchored textSizeVars type scale. Values are rem so icons track
152
+ the root font-size. */
153
+ xsm: {
154
+ width: '0.75rem',
155
+ height: '0.75rem',
156
+ fontSize: '0.75rem',
157
+ },
158
+ sm: {
159
+ width: '1rem',
160
+ height: '1rem',
161
+ fontSize: '1rem',
162
+ },
163
+ md: {
164
+ width: '1.25rem',
165
+ height: '1.25rem',
166
+ fontSize: '1.25rem',
167
+ },
168
+ lg: {
169
+ width: '1.5rem',
170
+ height: '1.5rem',
171
+ fontSize: '1.5rem',
172
+ },
173
+ /* eslint-enable @astryx/no-hardcoded-styles */
174
+ });
175
+
121
176
  // =============================================================================
122
177
  // Types
123
178
  // =============================================================================
124
179
 
125
180
  export type IconColor = keyof typeof colorStyles;
181
+ export type IconSize = keyof typeof sizeStyles;
126
182
 
127
183
  /**
128
184
  * Type for icon components that can be passed to Icon.
@@ -159,9 +215,7 @@ export interface IconProps extends Omit<
159
215
  * - 'sm': 1rem (16px at a 16px root)
160
216
  * - 'md': 1.25rem (20px at a 16px root)
161
217
  * - 'lg': 1.5rem (24px at a 16px root)
162
- *
163
- * An explicit value wins. When omitted, Icon uses the nearest default supplied
164
- * by an owning Astryx component for its icon slot, then falls back to 'md'.
218
+ * @default 'md'
165
219
  */
166
220
  size?: IconSize;
167
221
  /**
@@ -243,7 +297,7 @@ function getIconA11yProps(
243
297
  export function Icon({
244
298
  icon,
245
299
  color = 'inherit',
246
- size: sizeProp,
300
+ size = 'md',
247
301
  label,
248
302
  ref,
249
303
  className,
@@ -251,7 +305,6 @@ export function Icon({
251
305
  xstyle,
252
306
  ...props
253
307
  }: IconProps) {
254
- const size = useIconSize(sizeProp);
255
308
  // Derive ARIA from `label`: decorative (aria-hidden) by default, or a
256
309
  // meaningful image (role="img" + aria-label) when `label` is non-empty.
257
310
  const a11yProps = getIconA11yProps(label);
@@ -288,12 +341,7 @@ export function Icon({
288
341
  // precedence as escape hatches.
289
342
  {...mergeProps(
290
343
  themeProps('icon', {size, color}),
291
- stylex.props(
292
- styles.root,
293
- colorStyles[color],
294
- iconSizeStyles[size],
295
- xstyle,
296
- ),
344
+ stylex.props(styles.root, colorStyles[color], sizeStyles[size], xstyle),
297
345
  className ?? undefined,
298
346
  style,
299
347
  )}
@@ -362,7 +410,7 @@ function IconFromRegistry({
362
410
  stylex.props(
363
411
  styles.span,
364
412
  colorStyles[color],
365
- iconBoxSizeStyles[size],
413
+ spanSizeStyles[size],
366
414
  xstyle,
367
415
  ),
368
416
  className ?? undefined,
@@ -20,9 +20,9 @@ export const docs = {
20
20
  {
21
21
  name: 'icon',
22
22
  type: 'ReactNode',
23
- description: 'Icon element rendered inside the button. An Astryx Icon with no explicit size defaults to sm for sm/md buttons and md for lg buttons.',
23
+ description: 'Icon element rendered inside the button.',
24
24
  required: true,
25
- slotElements: [{__element: 'Icon', props: {icon: 'check'}}],
25
+ slotElements: [{__element: 'Icon', props: {icon: 'check', size: 'sm'}}],
26
26
  },
27
27
  {
28
28
  name: 'variant',
@@ -137,7 +137,7 @@ export const docsDense = {
137
137
  },
138
138
  propDescriptions: {
139
139
  label: 'accessible label; used as aria-label, not rendered as visible text',
140
- icon: 'icon element rendered inside button; unsized Astryx Icon defaults to sm for sm/md buttons and md for lg',
140
+ icon: 'icon element rendered inside button',
141
141
  variant: 'visual style variant',
142
142
  size: 'size variant',
143
143
  elevation: 'resting shadow depth: none|low|med|high; raise for a floating action button (FAB)',
@@ -12,7 +12,6 @@
12
12
  import {describe, it, expect, vi} from 'vitest';
13
13
  import {render, screen} from '@testing-library/react';
14
14
  import userEvent from '@testing-library/user-event';
15
- import {Icon} from '../Icon/Icon';
16
15
  import {IconButton} from './IconButton';
17
16
 
18
17
  describe('IconButton', () => {
@@ -53,45 +52,6 @@ describe('IconButton', () => {
53
52
  expect(screen.getByRole('button', {name: 'Add'})).toBeInTheDocument();
54
53
  });
55
54
 
56
- it.each([
57
- ['sm', 'sm', '1rem'],
58
- ['md', 'sm', '1rem'],
59
- ['lg', 'md', '1.25rem'],
60
- ] as const)(
61
- 'renders the %s button wrapper and inherited Icon at the same size',
62
- (buttonSize, iconSize, renderedSize) => {
63
- render(
64
- <IconButton
65
- label="Add"
66
- icon={<Icon icon="check" data-testid="icon" />}
67
- size={buttonSize}
68
- />,
69
- );
70
-
71
- const icon = screen.getByTestId('icon');
72
- const wrapper = icon.parentElement;
73
- expect(wrapper).not.toBeNull();
74
-
75
- expect(icon).toHaveAttribute('data-size', iconSize);
76
- expect(getComputedStyle(icon).width).toBe(renderedSize);
77
- expect(getComputedStyle(icon).height).toBe(renderedSize);
78
- expect(getComputedStyle(wrapper!).width).toBe(renderedSize);
79
- expect(getComputedStyle(wrapper!).height).toBe(renderedSize);
80
- },
81
- );
82
-
83
- it('preserves an explicit Astryx Icon size', () => {
84
- render(
85
- <IconButton
86
- label="Add"
87
- icon={<Icon icon="check" size="lg" data-testid="icon" />}
88
- size="sm"
89
- />,
90
- );
91
-
92
- expect(screen.getByTestId('icon')).toHaveAttribute('data-size', 'lg');
93
- });
94
-
95
55
  it('handles click events', async () => {
96
56
  const user = userEvent.setup();
97
57
  const handleClick = vi.fn();
@@ -43,7 +43,7 @@ export const docs = {
43
43
  {name: 'layout', type: "'stacked' | 'inline'", description: 'How the label and description sit together. stacked puts the description on its own line below the label; inline keeps both on one line, description ellipsizing first, so the row fits a fixed-height host.', default: "'stacked'"},
44
44
  {name: 'onClick', type: '(event: MouseEvent) => void', description: 'Click handler. Makes the item clickable with button semantics.'},
45
45
  {name: 'interactiveRef', type: 'RefObject<HTMLElement | null>', description: 'Ref to a nested control (e.g. a checkbox in startContent) that owns the item\'s keyboard access and action. The row becomes an enlarged click/tap target that delegates surface clicks to it (useClickableContainer) and renders no invisible button/anchor, so the row adds no second tab stop (WCAG 4.1.2). Mutually exclusive with onClick/href; those are ignored when set.'},
46
- {name: 'href', type: 'string', description: 'Link URL. Makes the item a link via an invisible anchor element. The destination follows the shared navigation rule described on the Link `href` prop.'},
46
+ {name: 'href', type: 'string', description: 'Link URL. Makes the item a link via an invisible anchor element.'},
47
47
  {name: 'target', type: "'_blank' | '_self'", description: 'Link target. Only used with href. target="_blank" automatically adds noopener noreferrer.'},
48
48
  {name: 'rel', type: 'string', description: 'Link relationship tokens. noopener noreferrer are merged automatically for target="_blank".'},
49
49
  {name: 'isHighlighted', type: 'boolean', description: 'Highlighted state (hover/keyboard focus appearance).', default: 'false'},
@@ -167,7 +167,7 @@ export const docsDense = {
167
167
  onClick: 'Click handler; enables button semantics.',
168
168
  interactiveRef:
169
169
  'Ref to a nested control that owns the item\'s keyboard access/action; row delegates surface clicks to it (useClickableContainer), no invisible button/anchor, no second tab stop (WCAG 4.1.2). Mutually exclusive with onClick/href.',
170
- href: 'Link URL; enables anchor semantics. Follows the shared navigation rule (see Link href).',
170
+ href: 'Link URL; enables anchor semantics.',
171
171
  target:
172
172
  'Link target, only with href. target="_blank" auto-adds noopener noreferrer.',
173
173
  rel: 'Link relationship tokens. noopener noreferrer are merged for target="_blank".',
@@ -38,8 +38,7 @@ export const docs = {
38
38
  {
39
39
  name: 'href',
40
40
  type: 'string',
41
- description:
42
- 'Link destination URL. Astryx-owned links and clickable surfaces block javascript:, vbscript:, and data:text/html after control-character removal, outer-whitespace trimming, and case-insensitive scheme inspection. The same rule covers native anchors, custom routers, Markdown links, and every activation method. Relative paths, fragments, protocol-relative URLs, HTTP(S), mailto, tel, safe custom schemes, and accepted downloads keep their existing behavior. A rejected destination renders inertly without invoking a custom router. There is no bypass prop; exceptional behavior requires caller-owned custom rendering outside this guarantee. Images and other resource URLs have separate policies.',
41
+ description: 'Link destination URL',
43
42
  },
44
43
  {
45
44
  name: 'hasUnderline',
@@ -152,8 +151,7 @@ export const docs = {
152
151
  {
153
152
  name: 'component',
154
153
  type: 'LinkComponentType',
155
- description:
156
- 'Component to use for all link elements. It receives accepted `href` and `to` values under the shared navigation rule described on the Link `href` prop. Supported structured destinations, including their `protocol`, are checked without changing object identity. If either supplied destination is rejected, Astryx renders inert content without invoking this component; it does not pass undefined or fall back to the other destination.',
154
+ description: 'Component to use for all link elements',
157
155
  required: true,
158
156
  },
159
157
  {
@@ -357,7 +355,7 @@ export const docsDense = {
357
355
  propDescriptions: {
358
356
  as: 'Custom component to render instead of <a>',
359
357
  label: 'Accessible label (aria-label). Only for non-text content like icon-only links.',
360
- href: 'Link destination URL. Shared rule blocks javascript:, vbscript:, data:text/html after scheme normalization on every Astryx-owned navigation path. Ordinary URLs, fragments, safe custom schemes, and accepted downloads are preserved. Rejected => inert, no custom router. No bypass prop; exceptional caller-owned rendering is outside the guarantee. Image/resource policy is separate.',
358
+ href: 'Link destination URL',
361
359
  hasUnderline: 'Always show underline',
362
360
  isDisabled: 'Disables link',
363
361
  isExternalLink: 'Opens new tab w/ external icon and safe rel tokens',
@@ -378,7 +376,7 @@ export const docsDense = {
378
376
  description:
379
377
  'Provider setting default link component for all Astryx links in subtree.',
380
378
  propDescriptions: {
381
- component: 'Component for all link elements. Accepted href/to follow the Link href rule; structured fields including protocol are checked, preserving identity. Either rejected => inert content, no router invocation or fallback.',
379
+ component: 'Component for all link elements',
382
380
  children: 'Subtree',
383
381
  },
384
382
  },
@@ -13,7 +13,7 @@ export const docs = {
13
13
  description: 'Wraps your app to replace the default <a> tag with a framework-specific link component (e.g. Next.js Link) for client-side routing across all Astryx components.',
14
14
  },
15
15
  props: [
16
- {name: 'component', type: 'LinkComponentType', required: true, description: 'Link component to use for all link elements in the subtree (e.g. Next.js Link). It receives accepted `href` and `to` values under the shared navigation rule described on the Link `href` prop. Supported structured destinations, including their `protocol`, are checked without changing object identity. If either supplied destination is rejected, Astryx renders inert content without invoking this component; it does not pass undefined or fall back to the other destination.'},
16
+ {name: 'component', type: 'LinkComponentType', required: true, description: 'Link component to use for all link elements in the subtree (e.g. Next.js Link).'},
17
17
  {name: 'children', type: 'ReactNode', required: true, description: 'Content to render with the link provider.'},
18
18
  ],
19
19
  };
@@ -25,6 +25,6 @@ export const docsDense = {
25
25
  description: 'Wraps app to replace default <a> tag w/ framework-specific link component (e.g. Next.js Link) for client-side routing across all Astryx components.',
26
26
  },
27
27
  propDescriptions: {
28
- component: 'link component for all link elements in subtree (e.g. Next.js Link). Accepted href/to follow the Link href rule; structured fields including protocol are checked, preserving identity. Either rejected => inert content, no router invocation or fallback.',
28
+ component: 'link component for all link elements in subtree (e.g. Next.js Link)',
29
29
  },
30
30
  };
@@ -208,339 +208,6 @@ describe('useLinkComponent — to prop', () => {
208
208
  });
209
209
  });
210
210
 
211
- // =============================================================================
212
- // href scheme rule
213
- // =============================================================================
214
-
215
- describe('useLinkComponent — href scheme rule', () => {
216
- // Kept in a const so the JSX stays lint-clean — the test is ABOUT this URL.
217
- const scriptUrl = 'javascript:alert(1)';
218
-
219
- function HrefConsumer({href}: {href: string}) {
220
- const LinkComponent = useLinkComponent();
221
- return (
222
- <LinkComponent href={href} data-testid="resolved-link">
223
- Link
224
- </LinkComponent>
225
- );
226
- }
227
-
228
- function propSpy() {
229
- return vi.fn(
230
- ({
231
- children,
232
- ...props
233
- }: {
234
- children?: React.ReactNode;
235
- [key: string]: unknown;
236
- }) => (
237
- <a data-testid="spy-link" href={props.href as string}>
238
- {children}
239
- </a>
240
- ),
241
- );
242
- }
243
-
244
- it('forwards ordinary hrefs to a custom component untouched', () => {
245
- const spy = propSpy();
246
- function SpyLink(props: {[key: string]: unknown}) {
247
- return spy(props);
248
- }
249
- render(
250
- <LinkProvider component={SpyLink}>
251
- <HrefConsumer href="/docs/button" />
252
- </LinkProvider>,
253
- );
254
- expect(spy).toHaveBeenCalledWith(
255
- expect.objectContaining({href: '/docs/button', to: '/docs/button'}),
256
- );
257
- });
258
-
259
- it('withholds a script-scheme href from a custom component (href and to alike)', () => {
260
- const spy = propSpy();
261
- function SpyLink(props: {[key: string]: unknown}) {
262
- return spy(props);
263
- }
264
- render(
265
- <LinkProvider component={SpyLink}>
266
- {/* eslint-disable-next-line @eslint-react/dom-no-script-url -- the test proves this URL never navigates */}
267
- <HrefConsumer href={scriptUrl} />
268
- </LinkProvider>,
269
- );
270
- expect(spy).not.toHaveBeenCalled();
271
- expect(screen.getByTestId('resolved-link')).not.toHaveAttribute('href');
272
- });
273
-
274
- it('an explicit `to` passes the same check instead of riding through ...rest', () => {
275
- const spy = propSpy();
276
- function SpyLink(props: {[key: string]: unknown}) {
277
- return spy(props);
278
- }
279
- function ToConsumer({to}: {to: string}) {
280
- const LinkComponent = useLinkComponent();
281
- return (
282
- <LinkComponent href="/fallback" to={to} data-testid="resolved-link">
283
- Link
284
- </LinkComponent>
285
- );
286
- }
287
- render(
288
- <LinkProvider component={SpyLink}>
289
- {/* eslint-disable-next-line @eslint-react/dom-no-script-url -- the test proves this URL never reaches the router */}
290
- <ToConsumer to={scriptUrl} />
291
- </LinkProvider>,
292
- );
293
- expect(spy).not.toHaveBeenCalled();
294
- expect(screen.getByTestId('resolved-link')).not.toHaveAttribute('href');
295
- });
296
-
297
- it('an ordinary explicit `to` keeps its precedence over href', () => {
298
- const spy = propSpy();
299
- function SpyLink(props: {[key: string]: unknown}) {
300
- return spy(props);
301
- }
302
- function ToConsumer() {
303
- const LinkComponent = useLinkComponent();
304
- return (
305
- <LinkComponent
306
- href="/fallback"
307
- to="/routed"
308
- data-testid="resolved-link">
309
- Link
310
- </LinkComponent>
311
- );
312
- }
313
- render(
314
- <LinkProvider component={SpyLink}>
315
- <ToConsumer />
316
- </LinkProvider>,
317
- );
318
- expect(spy).toHaveBeenCalledWith(
319
- expect.objectContaining({href: '/fallback', to: '/routed'}),
320
- );
321
- });
322
- });
323
-
324
- // =============================================================================
325
- // structured router `to`
326
- // =============================================================================
327
-
328
- describe('useLinkComponent — structured router `to`', () => {
329
- const scriptUrl = 'javascript:alert(1)';
330
-
331
- function propSpy() {
332
- return vi.fn(
333
- ({
334
- children,
335
- ...props
336
- }: {
337
- children?: React.ReactNode;
338
- [key: string]: unknown;
339
- }) => (
340
- <a data-testid="spy-link" href={props.href as string}>
341
- {children}
342
- </a>
343
- ),
344
- );
345
- }
346
-
347
- function ToConsumer({to}: {to: unknown}) {
348
- const LinkComponent = useLinkComponent();
349
- return (
350
- <LinkComponent href="/fallback" to={to} data-testid="resolved-link">
351
- Link
352
- </LinkComponent>
353
- );
354
- }
355
-
356
- it('preserves a structured destination for the router (no crash, same object)', () => {
357
- const spy = propSpy();
358
- function SpyLink(props: {[key: string]: unknown}) {
359
- return spy(props);
360
- }
361
- const to = {pathname: '/docs', search: '?q=1', hash: '#top'};
362
- render(
363
- <LinkProvider component={SpyLink}>
364
- <ToConsumer to={to} />
365
- </LinkProvider>,
366
- );
367
- expect(spy).toHaveBeenCalledWith(
368
- expect.objectContaining({href: '/fallback', to}),
369
- );
370
- expect(spy.mock.calls[0][0].to).toBe(to);
371
- });
372
-
373
- it('preserves a structured destination that carries no pathname', () => {
374
- const spy = propSpy();
375
- function SpyLink(props: {[key: string]: unknown}) {
376
- return spy(props);
377
- }
378
- const to = {search: '?page=2'};
379
- render(
380
- <LinkProvider component={SpyLink}>
381
- <ToConsumer to={to} />
382
- </LinkProvider>,
383
- );
384
- expect(spy).toHaveBeenCalledWith(expect.objectContaining({to}));
385
- });
386
-
387
- it('withholds a structured destination whose pathname is a script scheme', () => {
388
- const spy = propSpy();
389
- function SpyLink(props: {[key: string]: unknown}) {
390
- return spy(props);
391
- }
392
- render(
393
- <LinkProvider component={SpyLink}>
394
- <ToConsumer to={{pathname: scriptUrl}} />
395
- </LinkProvider>,
396
- );
397
- expect(spy).not.toHaveBeenCalled();
398
- expect(screen.getByTestId('resolved-link')).not.toHaveAttribute('href');
399
- });
400
-
401
- it('withholds a structured destination whose href is a script scheme', () => {
402
- const spy = propSpy();
403
- function SpyLink(props: {[key: string]: unknown}) {
404
- return spy(props);
405
- }
406
- render(
407
- <LinkProvider component={SpyLink}>
408
- <ToConsumer to={{href: scriptUrl}} />
409
- </LinkProvider>,
410
- );
411
- expect(spy).not.toHaveBeenCalled();
412
- expect(screen.getByTestId('resolved-link')).not.toHaveAttribute('href');
413
- });
414
- });
415
-
416
- // =============================================================================
417
- // Shared native and router boundary
418
- // =============================================================================
419
-
420
- describe('useLinkComponent — rejected destinations are inert', () => {
421
- function Consumer({
422
- href,
423
- to,
424
- as,
425
- onClick,
426
- linkRef,
427
- }: {
428
- href?: unknown;
429
- to?: unknown;
430
- as?: LinkComponentType;
431
- onClick?: () => void;
432
- linkRef?: React.Ref<HTMLAnchorElement>;
433
- }) {
434
- const Component = useLinkComponent(as);
435
- return (
436
- <Component
437
- href={href}
438
- to={to}
439
- ref={linkRef}
440
- onClick={onClick}
441
- className="consumer-class"
442
- aria-label="Destination"
443
- data-testid="destination">
444
- Go
445
- </Component>
446
- );
447
- }
448
-
449
- for (const mode of ['native', 'provider', 'as'] as const) {
450
- it.each([
451
- {href: 'javascript:alert(1)'},
452
- {href: 'vbscript:MsgBox(1)'},
453
- {href: 'data:text/html,<b>x</b>'},
454
- {href: 'java\nscript:alert(1)'},
455
- {href: '/safe', to: 'javascript:alert(1)'},
456
- {href: 'vbscript:MsgBox(1)', to: '/safe'},
457
- {href: {protocol: 'javascript:', pathname: 'alert(1)'}},
458
- {href: '/safe', to: {protocol: 'data:', pathname: 'text/html,x'}},
459
- ])(`${mode}: rejects %j without calling a router`, props => {
460
- const router = vi.fn(() => {
461
- throw new Error('rejected router invoked');
462
- });
463
- const onClick = vi.fn();
464
- const linkRef = vi.fn();
465
- render(
466
- <LinkProvider component={mode === 'provider' ? router : 'a'}>
467
- <Consumer
468
- {...props}
469
- as={mode === 'as' ? router : undefined}
470
- onClick={onClick}
471
- linkRef={linkRef}
472
- />
473
- </LinkProvider>,
474
- );
475
- const node = screen.getByTestId('destination');
476
- expect(router).not.toHaveBeenCalled();
477
- expect(node.tagName).toBe('A');
478
- expect(node).not.toHaveAttribute('href');
479
- expect(node).not.toHaveAttribute('to');
480
- expect(node).not.toHaveAttribute('aria-disabled');
481
- expect(node).toHaveAttribute('aria-label', 'Destination');
482
- expect(node).toHaveClass('consumer-class');
483
- expect(linkRef.mock.calls[0][0]).toBe(node);
484
- node.click();
485
- expect(onClick).toHaveBeenCalledOnce();
486
- });
487
- }
488
-
489
- it.each([
490
- '/relative',
491
- '#fragment',
492
- 'https://example.com',
493
- 'http://example.com',
494
- 'mailto:a@example.com',
495
- 'tel:+1234567890',
496
- 'custom:document',
497
- 'data:text/plain,download',
498
- ])('preserves native href and download affordances for %s', href => {
499
- function Download() {
500
- const Component = useLinkComponent();
501
- return (
502
- <Component
503
- href={href}
504
- download="note.txt"
505
- target="_blank"
506
- rel="noopener"
507
- data-testid="download">
508
- Download
509
- </Component>
510
- );
511
- }
512
- render(<Download />);
513
- const node = screen.getByTestId('download');
514
- expect(node).toHaveAttribute('href', href);
515
- expect(node).toHaveAttribute('download', 'note.txt');
516
- expect(node).toHaveAttribute('target', '_blank');
517
- expect(node).toHaveAttribute('rel', 'noopener');
518
- });
519
-
520
- it('preserves both structured prop identities and safe explicit precedence', () => {
521
- const router = vi.fn<(props: {href?: unknown; to?: unknown}) => null>(
522
- () => null,
523
- );
524
- const href = {
525
- protocol: 'https:',
526
- hostname: 'example.com',
527
- pathname: '/docs',
528
- };
529
- const to = {pathname: '/next', search: '?q=1', hash: '#top'};
530
- render(<Consumer as={router} href={href} to={to} />);
531
- expect(router.mock.calls[0][0].href).toBe(href);
532
- expect(router.mock.calls[0][0].to).toBe(to);
533
- });
534
-
535
- it('keeps the native node stable while accepted props update', () => {
536
- const {rerender} = render(<Consumer href="/first" />);
537
- const node = screen.getByTestId('destination');
538
- rerender(<Consumer href="/second" />);
539
- expect(screen.getByTestId('destination')).toBe(node);
540
- expect(node).toHaveAttribute('href', '/second');
541
- });
542
- });
543
-
544
211
  // =============================================================================
545
212
  // LinkProvider
546
213
  // =============================================================================