@astryxdesign/core 0.4.2 → 0.4.3-canary.07f9998
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +45 -0
- package/README.md +79 -45
- package/dist/Avatar/Avatar.d.ts.map +1 -1
- package/dist/Avatar/Avatar.js +3 -19
- package/dist/Banner/Banner.d.ts +9 -2
- package/dist/Banner/Banner.d.ts.map +1 -1
- package/dist/Banner/Banner.js +84 -33
- package/dist/Banner/index.d.ts +14 -12
- package/dist/Banner/index.d.ts.map +1 -1
- package/dist/Banner/index.js +10 -8
- package/dist/BottomSheet/BottomSheet.d.ts +66 -0
- package/dist/BottomSheet/BottomSheet.d.ts.map +1 -0
- package/dist/BottomSheet/BottomSheet.js +381 -0
- package/dist/BottomSheet/BottomSheetPanel.d.ts +57 -0
- package/dist/BottomSheet/BottomSheetPanel.d.ts.map +1 -0
- package/dist/BottomSheet/BottomSheetPanel.js +395 -0
- package/dist/BottomSheet/BottomSheetSwitcher.d.ts +61 -0
- package/dist/BottomSheet/BottomSheetSwitcher.d.ts.map +1 -0
- package/dist/BottomSheet/BottomSheetSwitcher.js +410 -0
- package/dist/BottomSheet/BottomSheetSwitcherContext.d.ts +21 -0
- package/dist/BottomSheet/BottomSheetSwitcherContext.d.ts.map +1 -0
- package/dist/BottomSheet/BottomSheetSwitcherContext.js +13 -0
- package/dist/BottomSheet/index.d.ts +11 -0
- package/dist/BottomSheet/index.d.ts.map +1 -0
- package/dist/BottomSheet/index.js +12 -0
- package/dist/BottomSheet/snapOffsets.d.ts +72 -0
- package/dist/BottomSheet/snapOffsets.d.ts.map +1 -0
- package/dist/BottomSheet/snapOffsets.js +130 -0
- package/dist/BottomSheet/useMobileKeyboard.d.ts +25 -0
- package/dist/BottomSheet/useMobileKeyboard.d.ts.map +1 -0
- package/dist/BottomSheet/useMobileKeyboard.js +384 -0
- package/dist/BottomSheet/useSheetGestures.d.ts +170 -0
- package/dist/BottomSheet/useSheetGestures.d.ts.map +1 -0
- package/dist/BottomSheet/useSheetGestures.js +881 -0
- package/dist/Chat/ChatComposerInput.d.ts.map +1 -1
- package/dist/Chat/ChatComposerInput.js +3 -4
- package/dist/Chat/ChatTokenizedText.js +1 -1
- package/dist/ComplexSelector/ComplexSelector.d.ts +38 -4
- package/dist/ComplexSelector/ComplexSelector.d.ts.map +1 -1
- package/dist/ComplexSelector/ComplexSelector.js +98 -31
- package/dist/ComplexSelector/index.d.ts +2 -2
- package/dist/ComplexSelector/index.d.ts.map +1 -1
- package/dist/ComplexSelector/index.js +1 -1
- package/dist/ContextMenu/ContextMenu.d.ts.map +1 -1
- package/dist/ContextMenu/ContextMenu.js +4 -2
- package/dist/DateInput/DateInput.d.ts.map +1 -1
- package/dist/DateInput/DateInput.js +9 -1
- package/dist/DateTimeInput/DateTimeInput.d.ts.map +1 -1
- package/dist/DateTimeInput/DateTimeInput.js +31 -1
- package/dist/Dialog/Dialog.d.ts.map +1 -1
- package/dist/Dialog/Dialog.js +2 -1
- package/dist/Markdown/parser.d.ts.map +1 -1
- package/dist/Markdown/parser.js +55 -12
- package/dist/MobileNav/MobileNav.d.ts +12 -0
- package/dist/MobileNav/MobileNav.d.ts.map +1 -1
- package/dist/MobileNav/MobileNav.js +102 -30
- package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
- package/dist/MultiSelector/MultiSelector.js +10 -1
- package/dist/NumberInput/NumberInput.d.ts.map +1 -1
- package/dist/NumberInput/NumberInput.js +9 -1
- package/dist/PowerSearch/PowerSearch.d.ts.map +1 -1
- package/dist/PowerSearch/PowerSearch.js +4 -1
- package/dist/PowerSearch/PowerSearchEditPopover.js +1 -1
- package/dist/PowerSearch/formatFilterValue.d.ts.map +1 -1
- package/dist/PowerSearch/formatFilterValue.js +2 -4
- package/dist/Selector/Selector.d.ts.map +1 -1
- package/dist/Selector/Selector.js +10 -1
- package/dist/Switch/Switch.d.ts.map +1 -1
- package/dist/Switch/Switch.js +5 -1
- package/dist/Table/columnUtils.d.ts.map +1 -1
- package/dist/Table/columnUtils.js +4 -1
- package/dist/TextArea/TextArea.d.ts +6 -3
- package/dist/TextArea/TextArea.d.ts.map +1 -1
- package/dist/TextArea/TextArea.js +17 -6
- package/dist/TimeInput/TimeInput.d.ts.map +1 -1
- package/dist/TimeInput/TimeInput.js +8 -1
- package/dist/Tooltip/useTooltip.d.ts.map +1 -1
- package/dist/Tooltip/useTooltip.js +4 -1
- package/dist/TreeList/TreeList.js +2 -1
- package/dist/Typeahead/BaseTypeahead.js +1 -1
- package/dist/Typeahead/Typeahead.d.ts.map +1 -1
- package/dist/Typeahead/Typeahead.js +10 -1
- package/dist/astryx.css +25 -2
- package/dist/hooks/index.d.ts +8 -1
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js +8 -1
- package/dist/hooks/useFocusTrap.d.ts +0 -11
- package/dist/hooks/useFocusTrap.d.ts.map +1 -1
- package/dist/hooks/useFocusTrap.js +1 -11
- package/dist/hooks/useInputStatusIcon.d.ts.map +1 -1
- package/dist/hooks/useInputStatusIcon.js +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/theme/defineTheme.d.ts +30 -7
- package/dist/theme/defineTheme.d.ts.map +1 -1
- package/dist/theme/defineTheme.js +36 -47
- package/dist/theme/expandColorScale.d.ts +17 -2
- package/dist/theme/expandColorScale.d.ts.map +1 -1
- package/dist/theme/expandColorScale.js +63 -33
- package/dist/theme/mergeComponents.d.ts +20 -0
- package/dist/theme/mergeComponents.d.ts.map +1 -0
- package/dist/theme/mergeComponents.js +56 -0
- package/dist/theme/onMediaTokens.d.ts +6 -1
- package/dist/theme/onMediaTokens.d.ts.map +1 -1
- package/dist/theme/onMediaTokens.js +11 -3
- package/dist/utils/characters.d.ts +27 -0
- package/dist/utils/characters.d.ts.map +1 -0
- package/dist/utils/characters.js +83 -0
- package/dist/utils/ime.d.ts +5 -0
- package/dist/utils/ime.d.ts.map +1 -0
- package/dist/utils/ime.js +63 -0
- package/dist/utils/index.d.ts +2 -0
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +3 -1
- package/package.json +9 -8
- package/src/Avatar/Avatar.test.tsx +6 -1
- package/src/Avatar/Avatar.tsx +3 -21
- package/src/Banner/Banner.doc.mjs +12 -10
- package/src/Banner/Banner.test.tsx +108 -0
- package/src/Banner/Banner.tsx +124 -38
- package/src/Banner/index.ts +15 -13
- package/src/BottomSheet/BottomSheet.doc.mjs +266 -0
- package/src/BottomSheet/BottomSheet.test.tsx +1865 -0
- package/src/BottomSheet/BottomSheet.tsx +539 -0
- package/src/BottomSheet/BottomSheetPanel.test.tsx +240 -0
- package/src/BottomSheet/BottomSheetPanel.tsx +542 -0
- package/src/BottomSheet/BottomSheetSwitcher.doc.mjs +219 -0
- package/src/BottomSheet/BottomSheetSwitcher.test.tsx +850 -0
- package/src/BottomSheet/BottomSheetSwitcher.tsx +628 -0
- package/src/BottomSheet/BottomSheetSwitcherContext.ts +51 -0
- package/src/BottomSheet/index.ts +15 -0
- package/src/BottomSheet/snapOffsets.test.ts +187 -0
- package/src/BottomSheet/snapOffsets.ts +159 -0
- package/src/BottomSheet/useMobileKeyboard.ts +566 -0
- package/src/BottomSheet/useSheetGestures.test.ts +833 -0
- package/src/BottomSheet/useSheetGestures.ts +1333 -0
- package/src/Chat/ChatComposerInput.tsx +3 -4
- package/src/Chat/ChatTokenizedText.tsx +1 -1
- package/src/ComplexSelector/ComplexSelector.doc.mjs +55 -6
- package/src/ComplexSelector/ComplexSelector.test.tsx +197 -6
- package/src/ComplexSelector/ComplexSelector.tsx +156 -28
- package/src/ComplexSelector/index.ts +3 -1
- package/src/ContextMenu/ContextMenu.tsx +4 -2
- package/src/DateInput/DateInput.test.tsx +21 -0
- package/src/DateInput/DateInput.tsx +9 -1
- package/src/DateTimeInput/DateTimeInput.doc.mjs +4 -0
- package/src/DateTimeInput/DateTimeInput.test.tsx +137 -0
- package/src/DateTimeInput/DateTimeInput.tsx +39 -2
- package/src/Dialog/Dialog.tsx +2 -1
- package/src/Markdown/parser.test.ts +53 -0
- package/src/Markdown/parser.ts +53 -12
- package/src/MobileNav/MobileNav.tsx +128 -37
- package/src/MobileNav/MobileNavCloseEdgeCases.test.tsx +426 -0
- package/src/MobileNav/MobileNavCloseTiming.test.ts +58 -0
- package/src/MobileNav/MobileNavCloseVisibility.test.tsx +172 -0
- package/src/MultiSelector/MultiSelector.test.tsx +31 -0
- package/src/MultiSelector/MultiSelector.tsx +10 -1
- package/src/NumberInput/NumberInput.test.tsx +18 -0
- package/src/NumberInput/NumberInput.tsx +9 -1
- package/src/PowerSearch/PowerSearch.test.tsx +48 -3
- package/src/PowerSearch/PowerSearch.tsx +4 -1
- package/src/PowerSearch/PowerSearchEditPopover.tsx +1 -1
- package/src/PowerSearch/formatFilterValue.test.ts +22 -0
- package/src/PowerSearch/formatFilterValue.ts +2 -4
- package/src/Selector/Selector.test.tsx +33 -0
- package/src/Selector/Selector.tsx +10 -1
- package/src/Switch/Switch.test.tsx +16 -0
- package/src/Switch/Switch.tsx +7 -0
- package/src/Table/Table.test.tsx +6 -0
- package/src/Table/columnUtils.ts +3 -1
- package/src/TextArea/TextArea.doc.mjs +1 -1
- package/src/TextArea/TextArea.test.tsx +72 -0
- package/src/TextArea/TextArea.tsx +26 -8
- package/src/TimeInput/TimeInput.test.tsx +23 -0
- package/src/TimeInput/TimeInput.tsx +8 -0
- package/src/Tooltip/useTooltip.tsx +4 -1
- package/src/TreeList/TreeList.doc.mjs +2 -2
- package/src/TreeList/TreeList.tsx +1 -1
- package/src/Typeahead/BaseTypeahead.tsx +1 -1
- package/src/Typeahead/Typeahead.test.tsx +35 -0
- package/src/Typeahead/Typeahead.tsx +10 -1
- package/src/__tests__/stubMatchMedia.test.ts +79 -0
- package/src/__tests__/stubMatchMedia.ts +95 -0
- package/src/hooks/index.ts +9 -5
- package/src/hooks/useFocusTrap.ts +1 -14
- package/src/hooks/useInputStatusIcon.test.tsx +19 -0
- package/src/hooks/useInputStatusIcon.tsx +7 -0
- package/src/index.ts +1 -0
- package/src/theme/defineTheme.test.ts +127 -0
- package/src/theme/defineTheme.ts +70 -55
- package/src/theme/expandColorScale.test.ts +138 -0
- package/src/theme/expandColorScale.ts +89 -44
- package/src/theme/extensibleAxes.test.ts +365 -0
- package/src/theme/mergeComponents.ts +59 -0
- package/src/theme/onMediaTokens.ts +9 -2
- package/src/utils/characters.test.ts +141 -0
- package/src/utils/characters.ts +88 -0
- package/src/utils/ime.test.ts +32 -0
- package/src/utils/ime.ts +69 -0
- package/src/utils/index.ts +4 -0
- package/dist/astryx.umd.js +0 -147
- package/dist/astryx.umd.js.map +0 -7
|
@@ -69,6 +69,7 @@ import {
|
|
|
69
69
|
formatISOTime,
|
|
70
70
|
isTimeInRange,
|
|
71
71
|
adjustTime,
|
|
72
|
+
isImeKeyEvent,
|
|
72
73
|
mergeProps,
|
|
73
74
|
mergeRefs,
|
|
74
75
|
isFocusDetached,
|
|
@@ -742,6 +743,13 @@ export function DateTimeInput({
|
|
|
742
743
|
|
|
743
744
|
const handleDateKeyDown = useCallback(
|
|
744
745
|
(e: React.KeyboardEvent) => {
|
|
746
|
+
// Guard the composing keydown (fires before compositionend): an IME uses
|
|
747
|
+
// Enter to commit the candidate and Escape to cancel it, so without this
|
|
748
|
+
// a CJK user committing a syllable with Enter would commit the pending
|
|
749
|
+
// date instead. See utils/ime.ts.
|
|
750
|
+
if (isImeKeyEvent(e.nativeEvent)) {
|
|
751
|
+
return;
|
|
752
|
+
}
|
|
745
753
|
if (e.key === 'Escape' && popover.isOpen) {
|
|
746
754
|
e.preventDefault();
|
|
747
755
|
popover.hide();
|
|
@@ -835,6 +843,13 @@ export function DateTimeInput({
|
|
|
835
843
|
|
|
836
844
|
const handleTimeKeyDown = useCallback(
|
|
837
845
|
(e: KeyboardEvent<HTMLInputElement>) => {
|
|
846
|
+
// ArrowUp/ArrowDown step the time and preventDefault; an IME candidate
|
|
847
|
+
// window uses those same arrows to navigate candidates, so guard the
|
|
848
|
+
// composing keydown (fires before compositionend) to avoid stealing them
|
|
849
|
+
// mid-composition. See utils/ime.ts.
|
|
850
|
+
if (isImeKeyEvent(e.nativeEvent)) {
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
838
853
|
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
|
|
839
854
|
e.preventDefault();
|
|
840
855
|
|
|
@@ -960,7 +975,20 @@ export function DateTimeInput({
|
|
|
960
975
|
styles.iconButton,
|
|
961
976
|
isEffectivelyDisabled && styles.iconButtonDisabled,
|
|
962
977
|
)}>
|
|
963
|
-
<Icon
|
|
978
|
+
<Icon
|
|
979
|
+
icon="calendar"
|
|
980
|
+
size="sm"
|
|
981
|
+
color="secondary"
|
|
982
|
+
// Stable theme target on the calendar toggle glyph, so a theme
|
|
983
|
+
// can restyle just this icon (color, size, hover) — and each
|
|
984
|
+
// open/closed state — via `defineTheme`, mirroring
|
|
985
|
+
// `date-input-toggle-icon`. Same-element rules in
|
|
986
|
+
// @layer astryx-theme win over the icon's own base color/size,
|
|
987
|
+
// which a segment-level target could not reach.
|
|
988
|
+
{...themeProps('date-time-input-toggle-icon', {
|
|
989
|
+
state: popover.isOpen ? 'expanded' : 'collapsed',
|
|
990
|
+
})}
|
|
991
|
+
/>
|
|
964
992
|
</button>
|
|
965
993
|
<input
|
|
966
994
|
ref={mergeRefs(ref, dateInputRef)}
|
|
@@ -1039,7 +1067,16 @@ export function DateTimeInput({
|
|
|
1039
1067
|
),
|
|
1040
1068
|
)}>
|
|
1041
1069
|
<div {...stylex.props(styles.icon)}>
|
|
1042
|
-
<Icon
|
|
1070
|
+
<Icon
|
|
1071
|
+
icon="clock"
|
|
1072
|
+
size="sm"
|
|
1073
|
+
color="secondary"
|
|
1074
|
+
// Stable theme target on the leading clock glyph, so a theme can
|
|
1075
|
+
// restyle just this icon (color, size) via `defineTheme`. The
|
|
1076
|
+
// time segment has no toggle button — the clock is a static
|
|
1077
|
+
// leading affordance — so this carries no interactive state.
|
|
1078
|
+
{...themeProps('date-time-input-clock-icon')}
|
|
1079
|
+
/>
|
|
1043
1080
|
</div>
|
|
1044
1081
|
<input
|
|
1045
1082
|
ref={timeInputRef}
|
package/src/Dialog/Dialog.tsx
CHANGED
|
@@ -27,7 +27,8 @@ import {
|
|
|
27
27
|
import type {BaseProps} from '../BaseProps';
|
|
28
28
|
import * as stylex from '@stylexjs/stylex';
|
|
29
29
|
import {useScrollLock} from '../hooks/useScrollLock';
|
|
30
|
-
import {hasActiveFocusTrapEscape
|
|
30
|
+
import {hasActiveFocusTrapEscape} from '../hooks/useFocusTrap';
|
|
31
|
+
import {isImeKeyEvent} from '../utils/ime';
|
|
31
32
|
import {
|
|
32
33
|
colorVars,
|
|
33
34
|
radiusVars,
|
|
@@ -51,6 +51,48 @@ describe('parseInline', () => {
|
|
|
51
51
|
expect(result).toEqual([{type: 'image', src: 'img.png', alt: 'alt'}]);
|
|
52
52
|
});
|
|
53
53
|
|
|
54
|
+
it('rejects javascript: links as plain text (XSS prevention)', () => {
|
|
55
|
+
const result = parseInline('[click](javascript:alert(1))');
|
|
56
|
+
// Should be emitted as plain text, NOT as a link node
|
|
57
|
+
expect(result).toEqual([
|
|
58
|
+
{type: 'text', content: '[click](javascript:alert(1))'},
|
|
59
|
+
]);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('rejects javascript: with mixed case and whitespace (XSS prevention)', () => {
|
|
63
|
+
const result = parseInline('[click](JaVaScRiPt:alert(1))');
|
|
64
|
+
expect(result).toEqual([
|
|
65
|
+
{type: 'text', content: '[click](JaVaScRiPt:alert(1))'},
|
|
66
|
+
]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('rejects vbscript: links (XSS prevention)', () => {
|
|
70
|
+
const result = parseInline('[click](vbscript:MsgBox(1))');
|
|
71
|
+
expect(result).toEqual([
|
|
72
|
+
{type: 'text', content: '[click](vbscript:MsgBox(1))'},
|
|
73
|
+
]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('rejects data:text/html image src (XSS prevention)', () => {
|
|
77
|
+
const result = parseInline(
|
|
78
|
+
'</script>)',
|
|
79
|
+
);
|
|
80
|
+
expect(result).toEqual([
|
|
81
|
+
{
|
|
82
|
+
type: 'text',
|
|
83
|
+
content: '</script>)',
|
|
84
|
+
},
|
|
85
|
+
]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('allows normal http/https links', () => {
|
|
89
|
+
const result = parseInline('[safe](https://example.com)');
|
|
90
|
+
expect(result[0].type).toBe('link');
|
|
91
|
+
if (result[0].type === 'link') {
|
|
92
|
+
expect(result[0].href).toBe('https://example.com');
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
54
96
|
it('parses strikethrough', () => {
|
|
55
97
|
const result = parseInline('~~deleted~~');
|
|
56
98
|
expect(result[0].type).toBe('strikethrough');
|
|
@@ -727,6 +769,17 @@ describe('citation parsing', () => {
|
|
|
727
769
|
});
|
|
728
770
|
});
|
|
729
771
|
|
|
772
|
+
it('rejects <javascript:...> angle-bracket autolinks (XSS prevention)', () => {
|
|
773
|
+
const result = parseInline('see <javascript:alert(1)> ok', {
|
|
774
|
+
autolink: 'gfm',
|
|
775
|
+
});
|
|
776
|
+
// Should NOT produce a link node with javascript: href
|
|
777
|
+
const linkNodes = result.filter(
|
|
778
|
+
(n): n is Extract<typeof n, {type: 'link'}> => n.type === 'link',
|
|
779
|
+
);
|
|
780
|
+
expect(linkNodes).toHaveLength(0);
|
|
781
|
+
});
|
|
782
|
+
|
|
730
783
|
it('parses <email> angle-bracket autolinks', () => {
|
|
731
784
|
const result = parseInline('mail <user@example.com> please', {
|
|
732
785
|
autolink: 'gfm',
|
package/src/Markdown/parser.ts
CHANGED
|
@@ -275,7 +275,7 @@ function matchReferenceLink(
|
|
|
275
275
|
// matches nothing.
|
|
276
276
|
const label = rawLabel === '' ? linkText : rawLabel;
|
|
277
277
|
const href = linkDefs.get(normalizeLinkLabel(label));
|
|
278
|
-
if (href != null) {
|
|
278
|
+
if (href != null && isSafeUrl(href)) {
|
|
279
279
|
return {
|
|
280
280
|
node: {type: 'link', href, children: parseInlineImpl(linkText, opts)},
|
|
281
281
|
end: labelClose + 1,
|
|
@@ -290,7 +290,7 @@ function matchReferenceLink(
|
|
|
290
290
|
return null;
|
|
291
291
|
}
|
|
292
292
|
const href = linkDefs.get(normalizeLinkLabel(linkText));
|
|
293
|
-
if (href == null) {
|
|
293
|
+
if (href == null || !isSafeUrl(href)) {
|
|
294
294
|
return null;
|
|
295
295
|
}
|
|
296
296
|
return {
|
|
@@ -359,6 +359,31 @@ function isWordChar(ch: string | undefined): boolean {
|
|
|
359
359
|
return /\w/.test(ch);
|
|
360
360
|
}
|
|
361
361
|
|
|
362
|
+
// ---------------------------------------------------------------------------
|
|
363
|
+
// URL scheme sanitization
|
|
364
|
+
// ---------------------------------------------------------------------------
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Reject URLs with dangerous schemes (javascript:, vbscript:, data:) that
|
|
368
|
+
* could execute arbitrary code when rendered as link hrefs or image srcs.
|
|
369
|
+
* Returns true if the URL is safe to use, false otherwise.
|
|
370
|
+
*/
|
|
371
|
+
function isSafeUrl(url: string): boolean {
|
|
372
|
+
// Trim and collapse whitespace/control chars that browsers tolerate but
|
|
373
|
+
// could bypass a naive prefix check (e.g. "java\nscript:alert(1)").
|
|
374
|
+
// eslint-disable-next-line no-control-regex -- control chars are the bypass
|
|
375
|
+
const normalized = url.replace(/[\x00-\x1f\x7f]/g, '').trim();
|
|
376
|
+
const lower = normalized.toLowerCase();
|
|
377
|
+
if (
|
|
378
|
+
lower.startsWith('javascript:') ||
|
|
379
|
+
lower.startsWith('vbscript:') ||
|
|
380
|
+
lower.startsWith('data:text/html')
|
|
381
|
+
) {
|
|
382
|
+
return false;
|
|
383
|
+
}
|
|
384
|
+
return true;
|
|
385
|
+
}
|
|
386
|
+
|
|
362
387
|
// ---------------------------------------------------------------------------
|
|
363
388
|
// Inline parser
|
|
364
389
|
// ---------------------------------------------------------------------------
|
|
@@ -478,11 +503,17 @@ function parseInlineImpl(text: string, opts: ResolvedOptions): InlineNode[] {
|
|
|
478
503
|
if (altClose !== -1 && text[altClose + 1] === '(') {
|
|
479
504
|
const srcClose = findClosingParen(text, altClose + 2);
|
|
480
505
|
if (srcClose !== -1) {
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
}
|
|
506
|
+
const src = text.slice(altClose + 2, srcClose);
|
|
507
|
+
if (!isSafeUrl(src)) {
|
|
508
|
+
// Dangerous scheme — emit as plain text.
|
|
509
|
+
nodes.push({type: 'text', content: text.slice(i, srcClose + 1)});
|
|
510
|
+
} else {
|
|
511
|
+
nodes.push({
|
|
512
|
+
type: 'image',
|
|
513
|
+
src,
|
|
514
|
+
alt: text.slice(i + 2, altClose),
|
|
515
|
+
});
|
|
516
|
+
}
|
|
486
517
|
i = srcClose + 1;
|
|
487
518
|
continue;
|
|
488
519
|
}
|
|
@@ -515,11 +546,17 @@ function parseInlineImpl(text: string, opts: ResolvedOptions): InlineNode[] {
|
|
|
515
546
|
if (textClose !== -1 && text[textClose + 1] === '(') {
|
|
516
547
|
const urlClose = findClosingParen(text, textClose + 2);
|
|
517
548
|
if (urlClose !== -1) {
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
}
|
|
549
|
+
const href = text.slice(textClose + 2, urlClose);
|
|
550
|
+
if (!isSafeUrl(href)) {
|
|
551
|
+
// Dangerous scheme — emit as plain text instead of a link.
|
|
552
|
+
nodes.push({type: 'text', content: text.slice(i, urlClose + 1)});
|
|
553
|
+
} else {
|
|
554
|
+
nodes.push({
|
|
555
|
+
type: 'link',
|
|
556
|
+
href,
|
|
557
|
+
children: parseInlineImpl(text.slice(i + 1, textClose), opts),
|
|
558
|
+
});
|
|
559
|
+
}
|
|
523
560
|
i = urlClose + 1;
|
|
524
561
|
continue;
|
|
525
562
|
}
|
|
@@ -780,6 +817,10 @@ function scanAutolinksInText(text: string): AutolinkMatch[] {
|
|
|
780
817
|
let m: RegExpExecArray | null;
|
|
781
818
|
while ((m = re.exec(text)) !== null) {
|
|
782
819
|
const url = m[1];
|
|
820
|
+
// Skip dangerous URL schemes (javascript:, vbscript:, data:text/html)
|
|
821
|
+
if (!isSafeUrl(url)) {
|
|
822
|
+
continue;
|
|
823
|
+
}
|
|
783
824
|
matches.push({
|
|
784
825
|
start: m.index,
|
|
785
826
|
end: m.index + m[0].length,
|
|
@@ -77,7 +77,19 @@ const styles = stylex.create({
|
|
|
77
77
|
outline: 'none',
|
|
78
78
|
// Native <dialog> uses display:none when closed.
|
|
79
79
|
// Open state applied via isOpen prop to avoid :where([open]) specificity issues.
|
|
80
|
+
// `display` participates in the transition with allow-discrete so it flips
|
|
81
|
+
// to none only after the slide-out finishes. That also keeps the dialog
|
|
82
|
+
// rendered until close() has actually run: an open modal dialog that isn't
|
|
83
|
+
// rendered still blocks the whole document, and a browser that fails to
|
|
84
|
+
// un-block it on close leaves the page inert with no error (#4290).
|
|
85
|
+
// Deliberately not shortened under reduced motion: `display` is discrete,
|
|
86
|
+
// so a long hold animates nothing — it is only the window the close has to
|
|
87
|
+
// land inside. The visible transitions (the drawer's transform and the
|
|
88
|
+
// backdrop's opacity) are the ones that respect the preference.
|
|
80
89
|
display: 'none',
|
|
90
|
+
transitionProperty: 'display',
|
|
91
|
+
transitionDuration: durationVars['--duration-medium'],
|
|
92
|
+
transitionBehavior: 'allow-discrete',
|
|
81
93
|
},
|
|
82
94
|
open: {
|
|
83
95
|
display: 'flex',
|
|
@@ -183,6 +195,75 @@ const dynamicStyles = stylex.create({
|
|
|
183
195
|
}),
|
|
184
196
|
});
|
|
185
197
|
|
|
198
|
+
// =============================================================================
|
|
199
|
+
// Close timing
|
|
200
|
+
// =============================================================================
|
|
201
|
+
|
|
202
|
+
/** Longest the drawer will wait before closing, however long the hold is. */
|
|
203
|
+
const MAX_CLOSE_DELAY_MS = 250;
|
|
204
|
+
/** Fraction of the hold to close at, so the close never lands on its boundary. */
|
|
205
|
+
const CLOSE_WITHIN_HOLD = 0.6;
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Shortest duration in a `transition-duration` list, in ms; null if unreadable.
|
|
209
|
+
*
|
|
210
|
+
* Browsers serialise computed `<time>` values in seconds — an authored `410ms`
|
|
211
|
+
* reads back as `"0.41s"` and a list as `"0.41s, 0.12s"` — so the seconds branch
|
|
212
|
+
* is the one that runs outside tests. jsdom echoes an inline `250ms` back as-is
|
|
213
|
+
* and never resolves `var()`, so both units and the unreadable case are covered
|
|
214
|
+
* directly in MobileNavCloseTiming.test.ts rather than through the component.
|
|
215
|
+
*
|
|
216
|
+
* @internal Exported for unit tests.
|
|
217
|
+
*/
|
|
218
|
+
export function parseShortestDurationMs(value: string): number | null {
|
|
219
|
+
const durations = value
|
|
220
|
+
.split(',')
|
|
221
|
+
.map(part => {
|
|
222
|
+
const trimmed = part.trim();
|
|
223
|
+
const ms = Number.parseFloat(trimmed);
|
|
224
|
+
if (!Number.isFinite(ms)) {
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
return trimmed.endsWith('ms')
|
|
228
|
+
? ms
|
|
229
|
+
: trimmed.endsWith('s')
|
|
230
|
+
? ms * 1000
|
|
231
|
+
: null;
|
|
232
|
+
})
|
|
233
|
+
.filter((ms): ms is number => ms !== null);
|
|
234
|
+
|
|
235
|
+
return durations.length ? Math.min(...durations) : null;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* How long to wait before closing the native dialog.
|
|
240
|
+
*
|
|
241
|
+
* The drawer is only rendered for as long as its `display` transition runs, and
|
|
242
|
+
* closing an unrendered modal dialog is what leaves the page inert (#4290). So
|
|
243
|
+
* the close has to land inside that hold. The hold is `--duration-medium`,
|
|
244
|
+
* which themes rewrite — the shipped y2k theme sets it to exactly 250ms — so
|
|
245
|
+
* read the hold in effect rather than assuming it.
|
|
246
|
+
*/
|
|
247
|
+
function resolveCloseDelay(dialog: HTMLDialogElement): number {
|
|
248
|
+
// Reduced motion makes the close sooner; it must not make the hold shorter.
|
|
249
|
+
// Shrinking both leaves no slack — one slow frame between the commit and this
|
|
250
|
+
// macrotask and the drawer has already stopped being rendered.
|
|
251
|
+
const cap = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
|
252
|
+
? 0
|
|
253
|
+
: MAX_CLOSE_DELAY_MS;
|
|
254
|
+
|
|
255
|
+
const hold = parseShortestDurationMs(
|
|
256
|
+
window.getComputedStyle(dialog).transitionDuration,
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
// The hold is unreadable — an unresolved var() outside a real browser.
|
|
260
|
+
if (hold === null) {
|
|
261
|
+
return cap;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return hold <= 0 ? 0 : Math.min(cap, hold * CLOSE_WITHIN_HOLD);
|
|
265
|
+
}
|
|
266
|
+
|
|
186
267
|
// =============================================================================
|
|
187
268
|
// Types
|
|
188
269
|
// =============================================================================
|
|
@@ -320,6 +401,34 @@ export function MobileNav({
|
|
|
320
401
|
side === 'auto' ? 'end' : side,
|
|
321
402
|
);
|
|
322
403
|
|
|
404
|
+
// Resolve which edge the drawer slides from. Deliberately its own effect,
|
|
405
|
+
// declared before the open/close effect below so the trigger is still the
|
|
406
|
+
// active element when `side='auto'` reads it. Keeping it out of that effect
|
|
407
|
+
// is what stops a `side` change during a close from re-arming the delay: the
|
|
408
|
+
// CSS hold runs from the commit that started the slide-out and does not
|
|
409
|
+
// restart, so a fresh full delay could land after the drawer had already
|
|
410
|
+
// stopped being rendered — #4290 again.
|
|
411
|
+
useEffect(() => {
|
|
412
|
+
if (!isOpen) {
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
if (side === 'auto') {
|
|
417
|
+
const trigger = document.activeElement as HTMLElement | null;
|
|
418
|
+
if (trigger && trigger !== document.body) {
|
|
419
|
+
const rect = trigger.getBoundingClientRect();
|
|
420
|
+
const triggerCenter = rect.left + rect.width / 2;
|
|
421
|
+
// eslint-disable-next-line @eslint-react/set-state-in-effect -- side is resolved from trigger layout immediately before showModal()
|
|
422
|
+
setResolvedSide(
|
|
423
|
+
triggerCenter < window.innerWidth / 2 ? 'start' : 'end',
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
} else {
|
|
427
|
+
// eslint-disable-next-line @eslint-react/set-state-in-effect -- side prop changes must update the open dialog placement
|
|
428
|
+
setResolvedSide(side);
|
|
429
|
+
}
|
|
430
|
+
}, [isOpen, side]);
|
|
431
|
+
|
|
323
432
|
// Open/close the dialog via showModal()/close()
|
|
324
433
|
// close() is delayed so the slide-out transition can play.
|
|
325
434
|
useEffect(() => {
|
|
@@ -328,28 +437,7 @@ export function MobileNav({
|
|
|
328
437
|
return;
|
|
329
438
|
}
|
|
330
439
|
|
|
331
|
-
if (closeTimeoutRef.current) {
|
|
332
|
-
clearTimeout(closeTimeoutRef.current);
|
|
333
|
-
closeTimeoutRef.current = null;
|
|
334
|
-
}
|
|
335
|
-
|
|
336
440
|
if (isOpen) {
|
|
337
|
-
// Determine drawer side from trigger position when auto
|
|
338
|
-
if (side === 'auto') {
|
|
339
|
-
const trigger = document.activeElement as HTMLElement | null;
|
|
340
|
-
if (trigger && trigger !== document.body) {
|
|
341
|
-
const rect = trigger.getBoundingClientRect();
|
|
342
|
-
const triggerCenter = rect.left + rect.width / 2;
|
|
343
|
-
// eslint-disable-next-line @eslint-react/set-state-in-effect -- side is resolved from trigger layout immediately before showModal()
|
|
344
|
-
setResolvedSide(
|
|
345
|
-
triggerCenter < window.innerWidth / 2 ? 'start' : 'end',
|
|
346
|
-
);
|
|
347
|
-
}
|
|
348
|
-
} else {
|
|
349
|
-
// eslint-disable-next-line @eslint-react/set-state-in-effect -- side prop changes must update the open dialog placement
|
|
350
|
-
setResolvedSide(side);
|
|
351
|
-
}
|
|
352
|
-
|
|
353
441
|
if (!dialog.open) {
|
|
354
442
|
dialog.showModal();
|
|
355
443
|
}
|
|
@@ -360,13 +448,9 @@ export function MobileNav({
|
|
|
360
448
|
} else if (dialog.open) {
|
|
361
449
|
document.documentElement.style.overflow = '';
|
|
362
450
|
|
|
363
|
-
const duration = window.matchMedia('(prefers-reduced-motion: reduce)')
|
|
364
|
-
.matches
|
|
365
|
-
? 10
|
|
366
|
-
: 250;
|
|
367
451
|
closeTimeoutRef.current = setTimeout(() => {
|
|
368
452
|
dialog.close();
|
|
369
|
-
},
|
|
453
|
+
}, resolveCloseDelay(dialog));
|
|
370
454
|
}
|
|
371
455
|
|
|
372
456
|
return () => {
|
|
@@ -375,20 +459,27 @@ export function MobileNav({
|
|
|
375
459
|
closeTimeoutRef.current = null;
|
|
376
460
|
}
|
|
377
461
|
document.documentElement.style.overflow = '';
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
462
|
+
};
|
|
463
|
+
}, [isOpen]);
|
|
464
|
+
|
|
465
|
+
// Close the native dialog on unmount if it's still open. Inside AppShell the
|
|
466
|
+
// drawer is mounted in an <Activity> that switches to mode="hidden" when the
|
|
467
|
+
// drawer closes; React then runs effect cleanups (with a stale isOpen)
|
|
468
|
+
// instead of re-running the effect with isOpen=false, so the close branch
|
|
469
|
+
// above never fires. If we leave the <dialog> `open` here, showModal() is
|
|
470
|
+
// skipped on the next open (the dialog is already open in the hidden tree)
|
|
471
|
+
// and the drawer can never be re-opened — see MobileNavReopen.test.tsx.
|
|
472
|
+
// This must be a separate unmount-only effect: putting it in the open/close
|
|
473
|
+
// effect above would close the dialog on every isOpen flip and cut off the
|
|
474
|
+
// delayed slide-out close.
|
|
475
|
+
useEffect(() => {
|
|
476
|
+
const dialog = dialogRef.current;
|
|
477
|
+
return () => {
|
|
478
|
+
if (dialog?.open) {
|
|
388
479
|
dialog.close();
|
|
389
480
|
}
|
|
390
481
|
};
|
|
391
|
-
}, [
|
|
482
|
+
}, []);
|
|
392
483
|
|
|
393
484
|
// Handle native cancel event (Escape key) — prevent default and route through onOpenChange
|
|
394
485
|
const handleCancel = useCallback(
|