@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.
Files changed (214) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +79 -45
  3. package/dist/Avatar/Avatar.d.ts.map +1 -1
  4. package/dist/Avatar/Avatar.js +3 -19
  5. package/dist/Banner/Banner.d.ts +9 -2
  6. package/dist/Banner/Banner.d.ts.map +1 -1
  7. package/dist/Banner/Banner.js +84 -33
  8. package/dist/Banner/index.d.ts +14 -12
  9. package/dist/Banner/index.d.ts.map +1 -1
  10. package/dist/Banner/index.js +10 -8
  11. package/dist/BottomSheet/BottomSheet.d.ts +66 -0
  12. package/dist/BottomSheet/BottomSheet.d.ts.map +1 -0
  13. package/dist/BottomSheet/BottomSheet.js +392 -0
  14. package/dist/BottomSheet/BottomSheetPanel.d.ts +60 -0
  15. package/dist/BottomSheet/BottomSheetPanel.d.ts.map +1 -0
  16. package/dist/BottomSheet/BottomSheetPanel.js +398 -0
  17. package/dist/BottomSheet/BottomSheetSwitcher.d.ts +61 -0
  18. package/dist/BottomSheet/BottomSheetSwitcher.d.ts.map +1 -0
  19. package/dist/BottomSheet/BottomSheetSwitcher.js +410 -0
  20. package/dist/BottomSheet/BottomSheetSwitcherContext.d.ts +21 -0
  21. package/dist/BottomSheet/BottomSheetSwitcherContext.d.ts.map +1 -0
  22. package/dist/BottomSheet/BottomSheetSwitcherContext.js +13 -0
  23. package/dist/BottomSheet/index.d.ts +11 -0
  24. package/dist/BottomSheet/index.d.ts.map +1 -0
  25. package/dist/BottomSheet/index.js +12 -0
  26. package/dist/BottomSheet/snapOffsets.d.ts +72 -0
  27. package/dist/BottomSheet/snapOffsets.d.ts.map +1 -0
  28. package/dist/BottomSheet/snapOffsets.js +130 -0
  29. package/dist/BottomSheet/useMobileKeyboard.d.ts +35 -0
  30. package/dist/BottomSheet/useMobileKeyboard.d.ts.map +1 -0
  31. package/dist/BottomSheet/useMobileKeyboard.js +373 -0
  32. package/dist/BottomSheet/useSheetGestures.d.ts +180 -0
  33. package/dist/BottomSheet/useSheetGestures.d.ts.map +1 -0
  34. package/dist/BottomSheet/useSheetGestures.js +942 -0
  35. package/dist/Chat/ChatComposerInput.d.ts.map +1 -1
  36. package/dist/Chat/ChatComposerInput.js +3 -4
  37. package/dist/Chat/ChatTokenizedText.js +1 -1
  38. package/dist/ComplexSelector/ComplexSelector.d.ts +38 -4
  39. package/dist/ComplexSelector/ComplexSelector.d.ts.map +1 -1
  40. package/dist/ComplexSelector/ComplexSelector.js +98 -31
  41. package/dist/ComplexSelector/index.d.ts +2 -2
  42. package/dist/ComplexSelector/index.d.ts.map +1 -1
  43. package/dist/ComplexSelector/index.js +1 -1
  44. package/dist/ContextMenu/ContextMenu.d.ts.map +1 -1
  45. package/dist/ContextMenu/ContextMenu.js +4 -2
  46. package/dist/DateInput/DateInput.d.ts.map +1 -1
  47. package/dist/DateInput/DateInput.js +9 -1
  48. package/dist/DateTimeInput/DateTimeInput.d.ts.map +1 -1
  49. package/dist/DateTimeInput/DateTimeInput.js +31 -1
  50. package/dist/Dialog/Dialog.d.ts.map +1 -1
  51. package/dist/Dialog/Dialog.js +2 -1
  52. package/dist/Lightbox/Lightbox.d.ts.map +1 -1
  53. package/dist/Lightbox/Lightbox.js +9 -3
  54. package/dist/Markdown/parser.d.ts.map +1 -1
  55. package/dist/Markdown/parser.js +55 -12
  56. package/dist/MobileNav/MobileNav.d.ts +12 -0
  57. package/dist/MobileNav/MobileNav.d.ts.map +1 -1
  58. package/dist/MobileNav/MobileNav.js +102 -30
  59. package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
  60. package/dist/MultiSelector/MultiSelector.js +21 -7
  61. package/dist/NumberInput/NumberInput.d.ts.map +1 -1
  62. package/dist/NumberInput/NumberInput.js +9 -1
  63. package/dist/PowerSearch/PowerSearch.d.ts.map +1 -1
  64. package/dist/PowerSearch/PowerSearch.js +4 -1
  65. package/dist/PowerSearch/PowerSearchEditPopover.js +1 -1
  66. package/dist/PowerSearch/formatFilterValue.d.ts.map +1 -1
  67. package/dist/PowerSearch/formatFilterValue.js +2 -4
  68. package/dist/Selector/Selector.d.ts.map +1 -1
  69. package/dist/Selector/Selector.js +14 -3
  70. package/dist/Switch/Switch.d.ts.map +1 -1
  71. package/dist/Switch/Switch.js +5 -1
  72. package/dist/Table/columnUtils.d.ts.map +1 -1
  73. package/dist/Table/columnUtils.js +4 -1
  74. package/dist/TextArea/TextArea.d.ts +6 -3
  75. package/dist/TextArea/TextArea.d.ts.map +1 -1
  76. package/dist/TextArea/TextArea.js +17 -6
  77. package/dist/TimeInput/TimeInput.d.ts.map +1 -1
  78. package/dist/TimeInput/TimeInput.js +8 -1
  79. package/dist/Tokenizer/Tokenizer.js +11 -5
  80. package/dist/Tooltip/useTooltip.d.ts.map +1 -1
  81. package/dist/Tooltip/useTooltip.js +4 -1
  82. package/dist/TreeList/TreeList.js +2 -1
  83. package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
  84. package/dist/Typeahead/BaseTypeahead.js +5 -3
  85. package/dist/Typeahead/Typeahead.d.ts.map +1 -1
  86. package/dist/Typeahead/Typeahead.js +10 -1
  87. package/dist/astryx.css +25 -2
  88. package/dist/hooks/index.d.ts +8 -1
  89. package/dist/hooks/index.d.ts.map +1 -1
  90. package/dist/hooks/index.js +8 -1
  91. package/dist/hooks/useFocusTrap.d.ts +0 -11
  92. package/dist/hooks/useFocusTrap.d.ts.map +1 -1
  93. package/dist/hooks/useFocusTrap.js +1 -11
  94. package/dist/hooks/useInputStatusIcon.d.ts.map +1 -1
  95. package/dist/hooks/useInputStatusIcon.js +1 -0
  96. package/dist/index.d.ts +1 -0
  97. package/dist/index.d.ts.map +1 -1
  98. package/dist/index.js +1 -0
  99. package/dist/theme/defineTheme.d.ts +30 -7
  100. package/dist/theme/defineTheme.d.ts.map +1 -1
  101. package/dist/theme/defineTheme.js +36 -47
  102. package/dist/theme/expandColorScale.d.ts +17 -2
  103. package/dist/theme/expandColorScale.d.ts.map +1 -1
  104. package/dist/theme/expandColorScale.js +63 -33
  105. package/dist/theme/mergeComponents.d.ts +20 -0
  106. package/dist/theme/mergeComponents.d.ts.map +1 -0
  107. package/dist/theme/mergeComponents.js +56 -0
  108. package/dist/theme/onMediaTokens.d.ts +6 -1
  109. package/dist/theme/onMediaTokens.d.ts.map +1 -1
  110. package/dist/theme/onMediaTokens.js +11 -3
  111. package/dist/utils/characters.d.ts +27 -0
  112. package/dist/utils/characters.d.ts.map +1 -0
  113. package/dist/utils/characters.js +83 -0
  114. package/dist/utils/ime.d.ts +5 -0
  115. package/dist/utils/ime.d.ts.map +1 -0
  116. package/dist/utils/ime.js +63 -0
  117. package/dist/utils/index.d.ts +2 -0
  118. package/dist/utils/index.d.ts.map +1 -1
  119. package/dist/utils/index.js +3 -1
  120. package/locales/en.json +48 -0
  121. package/locales/pseudo.json +36 -0
  122. package/package.json +9 -8
  123. package/src/Avatar/Avatar.test.tsx +6 -1
  124. package/src/Avatar/Avatar.tsx +3 -21
  125. package/src/Banner/Banner.doc.mjs +12 -10
  126. package/src/Banner/Banner.test.tsx +108 -0
  127. package/src/Banner/Banner.tsx +124 -38
  128. package/src/Banner/index.ts +15 -13
  129. package/src/BottomSheet/BottomSheet.doc.mjs +266 -0
  130. package/src/BottomSheet/BottomSheet.test.tsx +2114 -0
  131. package/src/BottomSheet/BottomSheet.tsx +545 -0
  132. package/src/BottomSheet/BottomSheetPanel.test.tsx +240 -0
  133. package/src/BottomSheet/BottomSheetPanel.tsx +547 -0
  134. package/src/BottomSheet/BottomSheetSwitcher.doc.mjs +219 -0
  135. package/src/BottomSheet/BottomSheetSwitcher.test.tsx +850 -0
  136. package/src/BottomSheet/BottomSheetSwitcher.tsx +628 -0
  137. package/src/BottomSheet/BottomSheetSwitcherContext.ts +51 -0
  138. package/src/BottomSheet/index.ts +15 -0
  139. package/src/BottomSheet/snapOffsets.test.ts +187 -0
  140. package/src/BottomSheet/snapOffsets.ts +159 -0
  141. package/src/BottomSheet/useMobileKeyboard.ts +499 -0
  142. package/src/BottomSheet/useSheetGestures.test.ts +1014 -0
  143. package/src/BottomSheet/useSheetGestures.ts +1407 -0
  144. package/src/Chat/ChatComposerInput.tsx +3 -4
  145. package/src/Chat/ChatTokenizedText.tsx +1 -1
  146. package/src/ComplexSelector/ComplexSelector.doc.mjs +55 -6
  147. package/src/ComplexSelector/ComplexSelector.test.tsx +197 -6
  148. package/src/ComplexSelector/ComplexSelector.tsx +156 -28
  149. package/src/ComplexSelector/index.ts +3 -1
  150. package/src/ContextMenu/ContextMenu.tsx +4 -2
  151. package/src/DateInput/DateInput.test.tsx +21 -0
  152. package/src/DateInput/DateInput.tsx +9 -1
  153. package/src/DateTimeInput/DateTimeInput.doc.mjs +4 -0
  154. package/src/DateTimeInput/DateTimeInput.test.tsx +137 -0
  155. package/src/DateTimeInput/DateTimeInput.tsx +39 -2
  156. package/src/Dialog/Dialog.tsx +2 -1
  157. package/src/FileInput/FileInput.test.tsx +53 -9
  158. package/src/Lightbox/Lightbox.test.tsx +49 -28
  159. package/src/Lightbox/Lightbox.tsx +7 -3
  160. package/src/Markdown/parser.test.ts +53 -0
  161. package/src/Markdown/parser.ts +53 -12
  162. package/src/MobileNav/MobileNav.tsx +128 -37
  163. package/src/MobileNav/MobileNavCloseEdgeCases.test.tsx +426 -0
  164. package/src/MobileNav/MobileNavCloseTiming.test.ts +58 -0
  165. package/src/MobileNav/MobileNavCloseVisibility.test.tsx +172 -0
  166. package/src/MultiSelector/MultiSelector.test.tsx +195 -51
  167. package/src/MultiSelector/MultiSelector.tsx +22 -8
  168. package/src/NumberInput/NumberInput.test.tsx +18 -0
  169. package/src/NumberInput/NumberInput.tsx +9 -1
  170. package/src/PowerSearch/PowerSearch.test.tsx +48 -3
  171. package/src/PowerSearch/PowerSearch.tsx +4 -1
  172. package/src/PowerSearch/PowerSearchEditPopover.tsx +1 -1
  173. package/src/PowerSearch/formatFilterValue.test.ts +22 -0
  174. package/src/PowerSearch/formatFilterValue.ts +2 -4
  175. package/src/Selector/Selector.test.tsx +85 -26
  176. package/src/Selector/Selector.tsx +13 -4
  177. package/src/Switch/Switch.test.tsx +16 -0
  178. package/src/Switch/Switch.tsx +7 -0
  179. package/src/Table/Table.test.tsx +6 -0
  180. package/src/Table/columnUtils.ts +3 -1
  181. package/src/TextArea/TextArea.doc.mjs +1 -1
  182. package/src/TextArea/TextArea.test.tsx +72 -0
  183. package/src/TextArea/TextArea.tsx +26 -8
  184. package/src/TimeInput/TimeInput.test.tsx +23 -0
  185. package/src/TimeInput/TimeInput.tsx +8 -0
  186. package/src/Tokenizer/Tokenizer.test.tsx +50 -32
  187. package/src/Tokenizer/Tokenizer.tsx +5 -5
  188. package/src/Tooltip/useTooltip.tsx +4 -1
  189. package/src/TreeList/TreeList.doc.mjs +2 -2
  190. package/src/TreeList/TreeList.tsx +1 -1
  191. package/src/Typeahead/BaseTypeahead.tsx +10 -3
  192. package/src/Typeahead/Typeahead.test.tsx +116 -7
  193. package/src/Typeahead/Typeahead.tsx +10 -1
  194. package/src/__tests__/stubMatchMedia.test.ts +79 -0
  195. package/src/__tests__/stubMatchMedia.ts +95 -0
  196. package/src/hooks/index.ts +9 -5
  197. package/src/hooks/useFocusTrap.ts +1 -14
  198. package/src/hooks/useInputStatusIcon.test.tsx +19 -0
  199. package/src/hooks/useInputStatusIcon.tsx +7 -0
  200. package/src/index.ts +1 -0
  201. package/src/theme/defineTheme.test.ts +127 -0
  202. package/src/theme/defineTheme.ts +70 -55
  203. package/src/theme/expandColorScale.test.ts +138 -0
  204. package/src/theme/expandColorScale.ts +89 -44
  205. package/src/theme/extensibleAxes.test.ts +365 -0
  206. package/src/theme/mergeComponents.ts +59 -0
  207. package/src/theme/onMediaTokens.ts +9 -2
  208. package/src/utils/characters.test.ts +141 -0
  209. package/src/utils/characters.ts +88 -0
  210. package/src/utils/ime.test.ts +32 -0
  211. package/src/utils/ime.ts +69 -0
  212. package/src/utils/index.ts +4 -0
  213. package/dist/astryx.umd.js +0 -147
  214. 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 icon="calendar" size="sm" color="secondary" />
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 icon="clock" size="sm" color="secondary" />
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}
@@ -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, isImeKeyEvent} from '../hooks/useFocusTrap';
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(<FileInput label="Upload" value={null} onChange={() => {}} />);
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
- expect(politeRegion()).toHaveTextContent('1 file selected: report.pdf');
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
- <FileInput
339
- label="Upload"
340
- value={null}
341
- onChange={() => {}}
342
- isMultiple
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
- expect(politeRegion()).toHaveTextContent('3 files selected');
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
- <Lightbox
324
- isOpen={true}
325
- onOpenChange={() => {}}
326
- media={media}
327
- defaultIndex={0}
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()).toHaveTextContent('Image B, 2 of 3');
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
- <Lightbox
339
- isOpen={true}
340
- onOpenChange={() => {}}
341
- media={media}
342
- defaultIndex={1}
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()).toHaveTextContent('Image C, 3 of 3');
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
- <Lightbox
355
- isOpen={true}
356
- onOpenChange={() => {}}
357
- media={media}
358
- defaultIndex={2}
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()).toHaveTextContent('Image B, 2 of 3');
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
- <Lightbox
374
- isOpen={true}
375
- onOpenChange={() => {}}
376
- media={unlabeled}
377
- defaultIndex={0}
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
- expect(politeRegion()).toHaveTextContent('Image 2 of 2');
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 = `${index + 1} of ${mediaArray.length}`;
382
- announce(item?.alt ? `${item.alt}, ${position}` : `Image ${position}`);
383
- }, [index, isOpen, announce, mediaArray]);
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
+ '![xss](data:text/html,<script>alert(1)</script>)',
79
+ );
80
+ expect(result).toEqual([
81
+ {
82
+ type: 'text',
83
+ content: '![xss](data:text/html,<script>alert(1)</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',
@@ -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
- nodes.push({
482
- type: 'image',
483
- src: text.slice(altClose + 2, srcClose),
484
- alt: text.slice(i + 2, altClose),
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
- nodes.push({
519
- type: 'link',
520
- href: text.slice(textClose + 2, urlClose),
521
- children: parseInlineImpl(text.slice(i + 1, textClose), opts),
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,