@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/Icon/Icon.test.tsx
CHANGED
|
@@ -162,13 +162,10 @@ describe('Icon', () => {
|
|
|
162
162
|
expect(icon).toHaveAttribute('aria-label', 'Home');
|
|
163
163
|
});
|
|
164
164
|
|
|
165
|
-
it('uses default color and
|
|
165
|
+
it('uses default color and size when not specified', () => {
|
|
166
166
|
render(<Icon icon={TestIcon} data-testid="icon" />);
|
|
167
|
-
|
|
168
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
|
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();
|
package/src/Item/Item.doc.mjs
CHANGED
|
@@ -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.
|
|
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.
|
|
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".',
|
package/src/Link/Link.doc.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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).
|
|
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)
|
|
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
|
// =============================================================================
|