@astryxdesign/core 0.4.2 → 0.4.3-canary.00bed29
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 +392 -0
- package/dist/BottomSheet/BottomSheetPanel.d.ts +60 -0
- package/dist/BottomSheet/BottomSheetPanel.d.ts.map +1 -0
- package/dist/BottomSheet/BottomSheetPanel.js +398 -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 +35 -0
- package/dist/BottomSheet/useMobileKeyboard.d.ts.map +1 -0
- package/dist/BottomSheet/useMobileKeyboard.js +373 -0
- package/dist/BottomSheet/useSheetGestures.d.ts +180 -0
- package/dist/BottomSheet/useSheetGestures.d.ts.map +1 -0
- package/dist/BottomSheet/useSheetGestures.js +942 -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/Lightbox/Lightbox.d.ts.map +1 -1
- package/dist/Lightbox/Lightbox.js +9 -3
- 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 +21 -7
- 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 +14 -3
- 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/Tokenizer/Tokenizer.js +11 -5
- 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.d.ts.map +1 -1
- package/dist/Typeahead/BaseTypeahead.js +5 -3
- 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/locales/en.json +48 -0
- package/locales/pseudo.json +36 -0
- 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 +2114 -0
- package/src/BottomSheet/BottomSheet.tsx +545 -0
- package/src/BottomSheet/BottomSheetPanel.test.tsx +240 -0
- package/src/BottomSheet/BottomSheetPanel.tsx +547 -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 +499 -0
- package/src/BottomSheet/useSheetGestures.test.ts +1014 -0
- package/src/BottomSheet/useSheetGestures.ts +1407 -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/FileInput/FileInput.test.tsx +53 -9
- package/src/Lightbox/Lightbox.test.tsx +49 -28
- package/src/Lightbox/Lightbox.tsx +7 -3
- 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 +195 -51
- package/src/MultiSelector/MultiSelector.tsx +22 -8
- 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 +85 -26
- package/src/Selector/Selector.tsx +13 -4
- 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/Tokenizer/Tokenizer.test.tsx +50 -32
- package/src/Tokenizer/Tokenizer.tsx +5 -5
- 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 +10 -3
- package/src/Typeahead/Typeahead.test.tsx +116 -7
- 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,
|
|
@@ -20,6 +20,12 @@ import {
|
|
|
20
20
|
import userEvent from '@testing-library/user-event';
|
|
21
21
|
import {FileInput} from './FileInput';
|
|
22
22
|
import {__resetLiveRegionsForTest} from '../hooks/useAnnounce';
|
|
23
|
+
import {InternationalizationProvider} from '../i18n';
|
|
24
|
+
|
|
25
|
+
// The `=1` branch names the file; the `other` branch must not. Both come from
|
|
26
|
+
// this test, so neither can pass against a hardcoded English string.
|
|
27
|
+
const FILE_SELECTED = 'Un fichier choisi : {fileName}';
|
|
28
|
+
const FILES_SELECTED = '{count, number} fichiers choisis';
|
|
23
29
|
|
|
24
30
|
afterEach(() => {
|
|
25
31
|
__resetLiveRegionsForTest();
|
|
@@ -324,23 +330,36 @@ describe('FileInput', () => {
|
|
|
324
330
|
|
|
325
331
|
describe('announcements', () => {
|
|
326
332
|
it('announces a single file selection politely', async () => {
|
|
327
|
-
render(
|
|
333
|
+
render(
|
|
334
|
+
<InternationalizationProvider
|
|
335
|
+
locale="fr"
|
|
336
|
+
overrides={{fr: {'@astryx.fileInput.fileSelected': FILE_SELECTED}}}>
|
|
337
|
+
<FileInput label="Upload" value={null} onChange={() => {}} />
|
|
338
|
+
</InternationalizationProvider>,
|
|
339
|
+
);
|
|
328
340
|
fireEvent.change(fileInputEl(), {
|
|
329
341
|
target: {files: [createFile('report.pdf', 100)]},
|
|
330
342
|
});
|
|
331
343
|
await waitFor(() => {
|
|
332
|
-
|
|
344
|
+
// The single-file key, carrying the file name.
|
|
345
|
+
expect(politeRegion()?.textContent).toBe(
|
|
346
|
+
'Un fichier choisi : report.pdf',
|
|
347
|
+
);
|
|
333
348
|
});
|
|
334
349
|
});
|
|
335
350
|
|
|
336
351
|
it('announces a multi-file count politely', async () => {
|
|
337
352
|
render(
|
|
338
|
-
<
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
353
|
+
<InternationalizationProvider
|
|
354
|
+
locale="fr"
|
|
355
|
+
overrides={{fr: {'@astryx.fileInput.filesSelected': FILES_SELECTED}}}>
|
|
356
|
+
<FileInput
|
|
357
|
+
label="Upload"
|
|
358
|
+
value={null}
|
|
359
|
+
onChange={() => {}}
|
|
360
|
+
isMultiple
|
|
361
|
+
/>
|
|
362
|
+
</InternationalizationProvider>,
|
|
344
363
|
);
|
|
345
364
|
const files = [
|
|
346
365
|
createFile('a.txt', 100),
|
|
@@ -349,7 +368,32 @@ describe('FileInput', () => {
|
|
|
349
368
|
];
|
|
350
369
|
fireEvent.change(fileInputEl(), {target: {files}});
|
|
351
370
|
await waitFor(() => {
|
|
352
|
-
|
|
371
|
+
// The multi-file key: a count, and no file name.
|
|
372
|
+
expect(politeRegion()?.textContent).toBe('3 fichiers choisis');
|
|
373
|
+
});
|
|
374
|
+
expect(politeRegion()?.textContent).not.toContain('a.txt');
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it('speaks the selection from a provider catalog', async () => {
|
|
378
|
+
render(
|
|
379
|
+
<InternationalizationProvider
|
|
380
|
+
locale="fr"
|
|
381
|
+
messages={{
|
|
382
|
+
fr: {
|
|
383
|
+
'@astryx.fileInput.fileSelected': {defaultMessage: FILE_SELECTED},
|
|
384
|
+
},
|
|
385
|
+
}}>
|
|
386
|
+
<FileInput label="Upload" value={null} onChange={() => {}} />
|
|
387
|
+
</InternationalizationProvider>,
|
|
388
|
+
);
|
|
389
|
+
fireEvent.change(fileInputEl(), {
|
|
390
|
+
target: {files: [createFile('report.pdf', 100)]},
|
|
391
|
+
});
|
|
392
|
+
await waitFor(() => {
|
|
393
|
+
// Same key through the catalog path rather than `overrides`.
|
|
394
|
+
expect(politeRegion()?.textContent).toBe(
|
|
395
|
+
'Un fichier choisi : report.pdf',
|
|
396
|
+
);
|
|
353
397
|
});
|
|
354
398
|
});
|
|
355
399
|
|
|
@@ -4,6 +4,7 @@ import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest';
|
|
|
4
4
|
import {render, screen, fireEvent, waitFor} from '@testing-library/react';
|
|
5
5
|
import {Lightbox} from './Lightbox';
|
|
6
6
|
import {__resetLiveRegionsForTest} from '../hooks/useAnnounce';
|
|
7
|
+
import {InternationalizationProvider} from '../i18n';
|
|
7
8
|
|
|
8
9
|
// Mock showModal/close for jsdom
|
|
9
10
|
beforeEach(() => {
|
|
@@ -27,6 +28,17 @@ function politeRegion(): HTMLElement | null {
|
|
|
27
28
|
return document.querySelector('[data-astryx-live-region="polite"]');
|
|
28
29
|
}
|
|
29
30
|
|
|
31
|
+
// Both position messages, supplied by the test. Overriding the pair means an
|
|
32
|
+
// assertion also proves which of the two keys the component reached for.
|
|
33
|
+
const POSITION_MESSAGES = {
|
|
34
|
+
fr: {
|
|
35
|
+
'@astryx.lightbox.mediaPosition':
|
|
36
|
+
'{alt}, vue {index, number} sur {total, number}',
|
|
37
|
+
'@astryx.lightbox.imagePosition':
|
|
38
|
+
'Photo {index, number} sur {total, number}',
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
|
|
30
42
|
describe('Lightbox', () => {
|
|
31
43
|
it('renders as a dialog element', () => {
|
|
32
44
|
render(
|
|
@@ -320,47 +332,53 @@ describe('Lightbox', () => {
|
|
|
320
332
|
|
|
321
333
|
it('announces the new image and position when navigating next via button', async () => {
|
|
322
334
|
render(
|
|
323
|
-
<
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
335
|
+
<InternationalizationProvider locale="fr" overrides={POSITION_MESSAGES}>
|
|
336
|
+
<Lightbox
|
|
337
|
+
isOpen={true}
|
|
338
|
+
onOpenChange={() => {}}
|
|
339
|
+
media={media}
|
|
340
|
+
defaultIndex={0}
|
|
341
|
+
/>
|
|
342
|
+
</InternationalizationProvider>,
|
|
329
343
|
);
|
|
330
344
|
fireEvent.click(screen.getByLabelText('Next'));
|
|
331
345
|
await waitFor(() => {
|
|
332
|
-
expect(politeRegion()).
|
|
346
|
+
expect(politeRegion()?.textContent).toBe('Image B, vue 2 sur 3');
|
|
333
347
|
});
|
|
334
348
|
});
|
|
335
349
|
|
|
336
350
|
it('announces the new image and position when navigating via arrow keys', async () => {
|
|
337
351
|
render(
|
|
338
|
-
<
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
352
|
+
<InternationalizationProvider locale="fr" overrides={POSITION_MESSAGES}>
|
|
353
|
+
<Lightbox
|
|
354
|
+
isOpen={true}
|
|
355
|
+
onOpenChange={() => {}}
|
|
356
|
+
media={media}
|
|
357
|
+
defaultIndex={1}
|
|
358
|
+
/>
|
|
359
|
+
</InternationalizationProvider>,
|
|
344
360
|
);
|
|
345
361
|
const dialog = document.querySelector('dialog')!;
|
|
346
362
|
fireEvent.keyDown(dialog, {key: 'ArrowRight'});
|
|
347
363
|
await waitFor(() => {
|
|
348
|
-
expect(politeRegion()).
|
|
364
|
+
expect(politeRegion()?.textContent).toBe('Image C, vue 3 sur 3');
|
|
349
365
|
});
|
|
350
366
|
});
|
|
351
367
|
|
|
352
368
|
it('announces the new image and position when navigating prev', async () => {
|
|
353
369
|
render(
|
|
354
|
-
<
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
370
|
+
<InternationalizationProvider locale="fr" overrides={POSITION_MESSAGES}>
|
|
371
|
+
<Lightbox
|
|
372
|
+
isOpen={true}
|
|
373
|
+
onOpenChange={() => {}}
|
|
374
|
+
media={media}
|
|
375
|
+
defaultIndex={2}
|
|
376
|
+
/>
|
|
377
|
+
</InternationalizationProvider>,
|
|
360
378
|
);
|
|
361
379
|
fireEvent.click(screen.getByLabelText('Previous'));
|
|
362
380
|
await waitFor(() => {
|
|
363
|
-
expect(politeRegion()).
|
|
381
|
+
expect(politeRegion()?.textContent).toBe('Image B, vue 2 sur 3');
|
|
364
382
|
});
|
|
365
383
|
});
|
|
366
384
|
|
|
@@ -370,16 +388,19 @@ describe('Lightbox', () => {
|
|
|
370
388
|
{src: '/b.jpg', alt: ''},
|
|
371
389
|
];
|
|
372
390
|
render(
|
|
373
|
-
<
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
391
|
+
<InternationalizationProvider locale="fr" overrides={POSITION_MESSAGES}>
|
|
392
|
+
<Lightbox
|
|
393
|
+
isOpen={true}
|
|
394
|
+
onOpenChange={() => {}}
|
|
395
|
+
media={unlabeled}
|
|
396
|
+
defaultIndex={0}
|
|
397
|
+
/>
|
|
398
|
+
</InternationalizationProvider>,
|
|
379
399
|
);
|
|
380
400
|
fireEvent.click(screen.getByLabelText('Next'));
|
|
381
401
|
await waitFor(() => {
|
|
382
|
-
|
|
402
|
+
// imagePosition, not a mediaPosition with an empty {alt}.
|
|
403
|
+
expect(politeRegion()?.textContent).toBe('Photo 2 sur 2');
|
|
383
404
|
});
|
|
384
405
|
});
|
|
385
406
|
|
|
@@ -378,9 +378,13 @@ export function Lightbox({
|
|
|
378
378
|
return;
|
|
379
379
|
}
|
|
380
380
|
const item = mediaArray[Math.min(index, mediaArray.length - 1)];
|
|
381
|
-
const position =
|
|
382
|
-
announce(
|
|
383
|
-
|
|
381
|
+
const position = {index: index + 1, total: mediaArray.length};
|
|
382
|
+
announce(
|
|
383
|
+
item?.alt
|
|
384
|
+
? t('@astryx.lightbox.mediaPosition', {alt: item.alt, ...position})
|
|
385
|
+
: t('@astryx.lightbox.imagePosition', position),
|
|
386
|
+
);
|
|
387
|
+
}, [index, isOpen, announce, mediaArray, t]);
|
|
384
388
|
|
|
385
389
|
// Open/close dialog
|
|
386
390
|
useIsomorphicLayoutEffect(() => {
|
|
@@ -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,
|