@furystack/shades-common-components 12.2.0 → 12.4.0

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 (100) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/esm/components/form.d.ts +5 -2
  3. package/esm/components/form.d.ts.map +1 -1
  4. package/esm/components/form.js +28 -6
  5. package/esm/components/form.js.map +1 -1
  6. package/esm/components/form.spec.js +207 -0
  7. package/esm/components/form.spec.js.map +1 -1
  8. package/esm/components/index.d.ts +1 -0
  9. package/esm/components/index.d.ts.map +1 -1
  10. package/esm/components/index.js +1 -0
  11. package/esm/components/index.js.map +1 -1
  12. package/esm/components/markdown/index.d.ts +5 -0
  13. package/esm/components/markdown/index.d.ts.map +1 -0
  14. package/esm/components/markdown/index.js +5 -0
  15. package/esm/components/markdown/index.js.map +1 -0
  16. package/esm/components/markdown/markdown-display.d.ts +19 -0
  17. package/esm/components/markdown/markdown-display.d.ts.map +1 -0
  18. package/esm/components/markdown/markdown-display.js +149 -0
  19. package/esm/components/markdown/markdown-display.js.map +1 -0
  20. package/esm/components/markdown/markdown-display.spec.d.ts +2 -0
  21. package/esm/components/markdown/markdown-display.spec.d.ts.map +1 -0
  22. package/esm/components/markdown/markdown-display.spec.js +191 -0
  23. package/esm/components/markdown/markdown-display.spec.js.map +1 -0
  24. package/esm/components/markdown/markdown-editor.d.ts +25 -0
  25. package/esm/components/markdown/markdown-editor.d.ts.map +1 -0
  26. package/esm/components/markdown/markdown-editor.js +113 -0
  27. package/esm/components/markdown/markdown-editor.js.map +1 -0
  28. package/esm/components/markdown/markdown-editor.spec.d.ts +2 -0
  29. package/esm/components/markdown/markdown-editor.spec.d.ts.map +1 -0
  30. package/esm/components/markdown/markdown-editor.spec.js +111 -0
  31. package/esm/components/markdown/markdown-editor.spec.js.map +1 -0
  32. package/esm/components/markdown/markdown-input.d.ts +29 -0
  33. package/esm/components/markdown/markdown-input.d.ts.map +1 -0
  34. package/esm/components/markdown/markdown-input.js +100 -0
  35. package/esm/components/markdown/markdown-input.js.map +1 -0
  36. package/esm/components/markdown/markdown-input.spec.d.ts +2 -0
  37. package/esm/components/markdown/markdown-input.spec.d.ts.map +1 -0
  38. package/esm/components/markdown/markdown-input.spec.js +215 -0
  39. package/esm/components/markdown/markdown-input.spec.js.map +1 -0
  40. package/esm/components/markdown/markdown-parser.d.ts +82 -0
  41. package/esm/components/markdown/markdown-parser.d.ts.map +1 -0
  42. package/esm/components/markdown/markdown-parser.js +274 -0
  43. package/esm/components/markdown/markdown-parser.js.map +1 -0
  44. package/esm/components/markdown/markdown-parser.spec.d.ts +2 -0
  45. package/esm/components/markdown/markdown-parser.spec.d.ts.map +1 -0
  46. package/esm/components/markdown/markdown-parser.spec.js +229 -0
  47. package/esm/components/markdown/markdown-parser.spec.js.map +1 -0
  48. package/esm/components/styles.d.ts +1 -0
  49. package/esm/components/styles.d.ts.map +1 -1
  50. package/esm/components/styles.js.map +1 -1
  51. package/esm/components/typography.d.ts.map +1 -1
  52. package/esm/components/typography.js +26 -14
  53. package/esm/components/typography.js.map +1 -1
  54. package/esm/services/css-variable-theme.d.ts +3 -0
  55. package/esm/services/css-variable-theme.d.ts.map +1 -1
  56. package/esm/services/css-variable-theme.js +3 -0
  57. package/esm/services/css-variable-theme.js.map +1 -1
  58. package/esm/services/css-variable-theme.spec.js +3 -0
  59. package/esm/services/css-variable-theme.spec.js.map +1 -1
  60. package/esm/services/default-dark-palette.d.ts +8 -0
  61. package/esm/services/default-dark-palette.d.ts.map +1 -0
  62. package/esm/services/default-dark-palette.js +56 -0
  63. package/esm/services/default-dark-palette.js.map +1 -0
  64. package/esm/services/default-dark-theme.d.ts +3 -0
  65. package/esm/services/default-dark-theme.d.ts.map +1 -1
  66. package/esm/services/default-dark-theme.js +7 -4
  67. package/esm/services/default-dark-theme.js.map +1 -1
  68. package/esm/services/default-light-theme.d.ts +3 -0
  69. package/esm/services/default-light-theme.d.ts.map +1 -1
  70. package/esm/services/default-light-theme.js +3 -0
  71. package/esm/services/default-light-theme.js.map +1 -1
  72. package/esm/services/index.d.ts +1 -0
  73. package/esm/services/index.d.ts.map +1 -1
  74. package/esm/services/index.js +1 -0
  75. package/esm/services/index.js.map +1 -1
  76. package/esm/services/theme-provider-service.d.ts +10 -1
  77. package/esm/services/theme-provider-service.d.ts.map +1 -1
  78. package/esm/services/theme-provider-service.js.map +1 -1
  79. package/package.json +2 -2
  80. package/src/components/form.spec.tsx +309 -0
  81. package/src/components/form.tsx +31 -8
  82. package/src/components/index.ts +1 -0
  83. package/src/components/markdown/index.ts +4 -0
  84. package/src/components/markdown/markdown-display.spec.tsx +243 -0
  85. package/src/components/markdown/markdown-display.tsx +202 -0
  86. package/src/components/markdown/markdown-editor.spec.tsx +142 -0
  87. package/src/components/markdown/markdown-editor.tsx +167 -0
  88. package/src/components/markdown/markdown-input.spec.tsx +274 -0
  89. package/src/components/markdown/markdown-input.tsx +143 -0
  90. package/src/components/markdown/markdown-parser.spec.ts +258 -0
  91. package/src/components/markdown/markdown-parser.ts +333 -0
  92. package/src/components/styles.tsx +1 -0
  93. package/src/components/typography.tsx +28 -15
  94. package/src/services/css-variable-theme.spec.ts +3 -0
  95. package/src/services/css-variable-theme.ts +3 -0
  96. package/src/services/default-dark-palette.ts +57 -0
  97. package/src/services/default-dark-theme.ts +7 -4
  98. package/src/services/default-light-theme.ts +3 -0
  99. package/src/services/index.ts +1 -0
  100. package/src/services/theme-provider-service.ts +7 -1
@@ -35,6 +35,18 @@ describe('FormService', () => {
35
35
  expect(service.inputs.size).toBe(0)
36
36
  })
37
37
  })
38
+
39
+ it('should initialize isSubmitting as false', () => {
40
+ using(new FormService(), (service) => {
41
+ expect(service.isSubmitting.getValue()).toBe(false)
42
+ })
43
+ })
44
+
45
+ it('should initialize submitError as undefined', () => {
46
+ using(new FormService(), (service) => {
47
+ expect(service.submitError.getValue()).toBeUndefined()
48
+ })
49
+ })
38
50
  })
39
51
 
40
52
  describe('setFieldState', () => {
@@ -84,12 +96,18 @@ describe('FormService', () => {
84
96
  const validatedFormDataDisposeSpy = vi.spyOn(service.validatedFormData, Symbol.dispose)
85
97
  const rawFormDataDisposeSpy = vi.spyOn(service.rawFormData, Symbol.dispose)
86
98
  const validationResultDisposeSpy = vi.spyOn(service.validationResult, Symbol.dispose)
99
+ const fieldErrorsDisposeSpy = vi.spyOn(service.fieldErrors, Symbol.dispose)
100
+ const isSubmittingDisposeSpy = vi.spyOn(service.isSubmitting, Symbol.dispose)
101
+ const submitErrorDisposeSpy = vi.spyOn(service.submitError, Symbol.dispose)
87
102
 
88
103
  service[Symbol.dispose]()
89
104
 
90
105
  expect(validatedFormDataDisposeSpy).toHaveBeenCalled()
91
106
  expect(rawFormDataDisposeSpy).toHaveBeenCalled()
92
107
  expect(validationResultDisposeSpy).toHaveBeenCalled()
108
+ expect(fieldErrorsDisposeSpy).toHaveBeenCalled()
109
+ expect(isSubmittingDisposeSpy).toHaveBeenCalled()
110
+ expect(submitErrorDisposeSpy).toHaveBeenCalled()
93
111
  })
94
112
  })
95
113
  })
@@ -488,4 +506,295 @@ describe('Form component', () => {
488
506
  await sleepAsync(50)
489
507
  })
490
508
  })
509
+
510
+ it('should set isSubmitting during async onSubmit and reset after', async () => {
511
+ await usingAsync(new Injector(), async (injector) => {
512
+ const rootElement = document.getElementById('root') as HTMLDivElement
513
+
514
+ let resolveSubmit: () => void
515
+ const submitPromise = new Promise<void>((resolve) => {
516
+ resolveSubmit = resolve
517
+ })
518
+
519
+ type FormData = { name: string }
520
+
521
+ initializeShadeRoot({
522
+ injector,
523
+ rootElement,
524
+ jsxElement: (
525
+ <Form<FormData>
526
+ onSubmit={() => submitPromise}
527
+ validate={(data): data is FormData => {
528
+ const d = data as Record<string, unknown>
529
+ return typeof d.name === 'string'
530
+ }}
531
+ >
532
+ <input name="name" type="text" />
533
+ <button type="submit">Submit</button>
534
+ </Form>
535
+ ),
536
+ })
537
+
538
+ await sleepAsync(50)
539
+
540
+ const form = document.querySelector('form[is="shade-form"]') as HTMLFormElement
541
+ const input = form.querySelector('input[name="name"]') as HTMLInputElement
542
+ input.value = 'Test'
543
+
544
+ const formInjector = (form as unknown as { injector: Injector }).injector
545
+ const formService = formInjector.getInstance(FormService)
546
+
547
+ expect(formService.isSubmitting.getValue()).toBe(false)
548
+
549
+ const submitEvent = new Event('submit', { bubbles: true, cancelable: true })
550
+ form.dispatchEvent(submitEvent)
551
+
552
+ await sleepAsync(50)
553
+ expect(formService.isSubmitting.getValue()).toBe(true)
554
+
555
+ resolveSubmit!()
556
+ await sleepAsync(50)
557
+ expect(formService.isSubmitting.getValue()).toBe(false)
558
+ })
559
+ })
560
+
561
+ it('should reset isSubmitting to false and set submitError when onSubmit throws', async () => {
562
+ await usingAsync(new Injector(), async (injector) => {
563
+ const rootElement = document.getElementById('root') as HTMLDivElement
564
+
565
+ type FormData = { name: string }
566
+
567
+ const submitError = new Error('Submit failed')
568
+
569
+ initializeShadeRoot({
570
+ injector,
571
+ rootElement,
572
+ jsxElement: (
573
+ <Form<FormData>
574
+ onSubmit={async () => {
575
+ throw submitError
576
+ }}
577
+ validate={(data): data is FormData => {
578
+ const d = data as Record<string, unknown>
579
+ return typeof d.name === 'string'
580
+ }}
581
+ >
582
+ <input name="name" type="text" />
583
+ <button type="submit">Submit</button>
584
+ </Form>
585
+ ),
586
+ })
587
+
588
+ await sleepAsync(50)
589
+
590
+ const form = document.querySelector('form[is="shade-form"]') as HTMLFormElement
591
+ const input = form.querySelector('input[name="name"]') as HTMLInputElement
592
+ input.value = 'Test'
593
+
594
+ const formInjector = (form as unknown as { injector: Injector }).injector
595
+ const formService = formInjector.getInstance(FormService)
596
+
597
+ const submitEvent = new Event('submit', { bubbles: true, cancelable: true })
598
+ form.dispatchEvent(submitEvent)
599
+
600
+ await sleepAsync(50)
601
+ expect(formService.isSubmitting.getValue()).toBe(false)
602
+ expect(formService.submitError.getValue()).toBe(submitError)
603
+ })
604
+ })
605
+
606
+ it('should clear submitError before a new submission', async () => {
607
+ await usingAsync(new Injector(), async (injector) => {
608
+ const rootElement = document.getElementById('root') as HTMLDivElement
609
+
610
+ let shouldThrow = true
611
+ let resolveSubmit: () => void
612
+
613
+ type FormData = { name: string }
614
+
615
+ initializeShadeRoot({
616
+ injector,
617
+ rootElement,
618
+ jsxElement: (
619
+ <Form<FormData>
620
+ onSubmit={async () => {
621
+ if (shouldThrow) {
622
+ throw new Error('First submit fails')
623
+ }
624
+ return new Promise<void>((resolve) => {
625
+ resolveSubmit = resolve
626
+ })
627
+ }}
628
+ validate={(data): data is FormData => {
629
+ const d = data as Record<string, unknown>
630
+ return typeof d.name === 'string'
631
+ }}
632
+ >
633
+ <input name="name" type="text" />
634
+ <button type="submit">Submit</button>
635
+ </Form>
636
+ ),
637
+ })
638
+
639
+ await sleepAsync(50)
640
+
641
+ const form = document.querySelector('form[is="shade-form"]') as HTMLFormElement
642
+ const input = form.querySelector('input[name="name"]') as HTMLInputElement
643
+ input.value = 'Test'
644
+
645
+ const formInjector = (form as unknown as { injector: Injector }).injector
646
+ const formService = formInjector.getInstance(FormService)
647
+
648
+ form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
649
+ await sleepAsync(50)
650
+ expect(formService.submitError.getValue()).toBeInstanceOf(Error)
651
+
652
+ shouldThrow = false
653
+ form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
654
+ await sleepAsync(50)
655
+ expect(formService.submitError.getValue()).toBeUndefined()
656
+ expect(formService.isSubmitting.getValue()).toBe(true)
657
+
658
+ resolveSubmit!()
659
+ await sleepAsync(50)
660
+ expect(formService.isSubmitting.getValue()).toBe(false)
661
+ })
662
+ })
663
+
664
+ it('should set inert on form element when disableOnSubmit is true during async submit', async () => {
665
+ await usingAsync(new Injector(), async (injector) => {
666
+ const rootElement = document.getElementById('root') as HTMLDivElement
667
+
668
+ let resolveSubmit: () => void
669
+ const submitPromise = new Promise<void>((resolve) => {
670
+ resolveSubmit = resolve
671
+ })
672
+
673
+ type FormData = { name: string }
674
+
675
+ initializeShadeRoot({
676
+ injector,
677
+ rootElement,
678
+ jsxElement: (
679
+ <Form<FormData>
680
+ onSubmit={() => submitPromise}
681
+ disableOnSubmit
682
+ validate={(data): data is FormData => {
683
+ const d = data as Record<string, unknown>
684
+ return typeof d.name === 'string'
685
+ }}
686
+ >
687
+ <input name="name" type="text" />
688
+ <button type="submit">Submit</button>
689
+ </Form>
690
+ ),
691
+ })
692
+
693
+ await sleepAsync(50)
694
+
695
+ const form = document.querySelector('form[is="shade-form"]') as HTMLFormElement
696
+ const input = form.querySelector('input[name="name"]') as HTMLInputElement
697
+ input.value = 'Test'
698
+
699
+ expect(form.inert).toBeFalsy()
700
+
701
+ const submitEvent = new Event('submit', { bubbles: true, cancelable: true })
702
+ form.dispatchEvent(submitEvent)
703
+
704
+ await sleepAsync(50)
705
+ expect(form.inert).toBe(true)
706
+
707
+ resolveSubmit!()
708
+ await sleepAsync(50)
709
+ expect(form.inert).toBe(false)
710
+ })
711
+ })
712
+
713
+ it('should not set inert when disableOnSubmit is not provided', async () => {
714
+ await usingAsync(new Injector(), async (injector) => {
715
+ const rootElement = document.getElementById('root') as HTMLDivElement
716
+
717
+ let resolveSubmit: () => void
718
+ const submitPromise = new Promise<void>((resolve) => {
719
+ resolveSubmit = resolve
720
+ })
721
+
722
+ type FormData = { name: string }
723
+
724
+ initializeShadeRoot({
725
+ injector,
726
+ rootElement,
727
+ jsxElement: (
728
+ <Form<FormData>
729
+ onSubmit={() => submitPromise}
730
+ validate={(data): data is FormData => {
731
+ const d = data as Record<string, unknown>
732
+ return typeof d.name === 'string'
733
+ }}
734
+ >
735
+ <input name="name" type="text" />
736
+ <button type="submit">Submit</button>
737
+ </Form>
738
+ ),
739
+ })
740
+
741
+ await sleepAsync(50)
742
+
743
+ const form = document.querySelector('form[is="shade-form"]') as HTMLFormElement
744
+ const input = form.querySelector('input[name="name"]') as HTMLInputElement
745
+ input.value = 'Test'
746
+
747
+ const submitEvent = new Event('submit', { bubbles: true, cancelable: true })
748
+ form.dispatchEvent(submitEvent)
749
+
750
+ await sleepAsync(50)
751
+ expect(form.inert).toBeFalsy()
752
+
753
+ resolveSubmit!()
754
+ await sleepAsync(50)
755
+ })
756
+ })
757
+
758
+ it('should remove inert even if onSubmit throws when disableOnSubmit is true', async () => {
759
+ await usingAsync(new Injector(), async (injector) => {
760
+ const rootElement = document.getElementById('root') as HTMLDivElement
761
+
762
+ type FormData = { name: string }
763
+
764
+ initializeShadeRoot({
765
+ injector,
766
+ rootElement,
767
+ jsxElement: (
768
+ <Form<FormData>
769
+ onSubmit={async () => {
770
+ throw new Error('Submit failed')
771
+ }}
772
+ disableOnSubmit
773
+ validate={(data): data is FormData => {
774
+ const d = data as Record<string, unknown>
775
+ return typeof d.name === 'string'
776
+ }}
777
+ >
778
+ <input name="name" type="text" />
779
+ <button type="submit">Submit</button>
780
+ </Form>
781
+ ),
782
+ })
783
+
784
+ await sleepAsync(50)
785
+
786
+ const form = document.querySelector('form[is="shade-form"]') as HTMLFormElement
787
+ const input = form.querySelector('input[name="name"]') as HTMLInputElement
788
+ input.value = 'Test'
789
+
790
+ const submitEvent = new Event('submit', { bubbles: true, cancelable: true })
791
+ form.dispatchEvent(submitEvent)
792
+
793
+ await sleepAsync(50)
794
+ expect(form.inert).toBe(false)
795
+ const formInjector = (form as unknown as { injector: Injector }).injector
796
+ const formService = formInjector.getInstance(FormService)
797
+ expect(formService.isSubmitting.getValue()).toBe(false)
798
+ })
799
+ })
491
800
  })
@@ -29,6 +29,10 @@ export class FormService<T> {
29
29
 
30
30
  public inputs = new Set<HTMLInputElement>()
31
31
 
32
+ public isSubmitting = new ObservableValue<boolean>(false)
33
+
34
+ public submitError = new ObservableValue<unknown>(undefined)
35
+
32
36
  public setFieldState = (key: keyof T, validationResult: InputValidationResult, validity: ValidityState) => {
33
37
  this.fieldErrors.setValue({ ...this.fieldErrors.getValue(), [key]: { validationResult, validity } })
34
38
  }
@@ -37,13 +41,17 @@ export class FormService<T> {
37
41
  this.validatedFormData[Symbol.dispose]()
38
42
  this.rawFormData[Symbol.dispose]()
39
43
  this.validationResult[Symbol.dispose]()
44
+ this.fieldErrors[Symbol.dispose]()
45
+ this.isSubmitting[Symbol.dispose]()
46
+ this.submitError[Symbol.dispose]()
40
47
  }
41
48
  }
42
49
 
43
50
  type FormProps<T> = {
44
- onSubmit: (formData: T) => void
51
+ onSubmit: (formData: T) => void | Promise<void>
45
52
  onReset?: () => void
46
- validate: (formData: any) => formData is T
53
+ validate: (formData: unknown) => formData is T
54
+ disableOnSubmit?: boolean
47
55
  } & PartialElement<Omit<HTMLFormElement, 'onsubmit' | 'onchange' | 'onreset'>>
48
56
 
49
57
  export const Form: <T>(props: FormProps<T>, children: ChildrenList) => JSX.Element = Shade({
@@ -61,13 +69,14 @@ export const Form: <T>(props: FormProps<T>, children: ChildrenList) => JSX.Eleme
61
69
  // `injector` setter defined on the Shade base class.
62
70
  useHostProps({ injector: formInjector })
63
71
 
64
- const changeHandler = (ev: Event, shouldSubmit?: boolean) => {
72
+ const changeHandler = async (ev: Event, shouldSubmit?: boolean) => {
65
73
  formService.inputs.forEach((i) => {
66
74
  const e = document.createEvent('FocusEvent')
67
75
  e.initEvent('blur', true, true)
68
76
  i.dispatchEvent(e)
69
77
  })
70
- const formData = Object.fromEntries(new FormData(ev.currentTarget as HTMLFormElement).entries())
78
+ const formElement = ev.currentTarget as HTMLFormElement
79
+ const formData = Object.fromEntries(new FormData(formElement).entries())
71
80
  formService.rawFormData.setValue(formData)
72
81
  const currentFieldErrors = formService.fieldErrors.getValue()
73
82
 
@@ -80,7 +89,21 @@ export const Form: <T>(props: FormProps<T>, children: ChildrenList) => JSX.Eleme
80
89
  formService.validationResult.setValue({ isValid: true })
81
90
  formService.validatedFormData.setValue(formData)
82
91
  if (shouldSubmit) {
83
- props.onSubmit(formData)
92
+ formService.isSubmitting.setValue(true)
93
+ formService.submitError.setValue(undefined)
94
+ if (props.disableOnSubmit) {
95
+ formElement.inert = true
96
+ }
97
+ try {
98
+ await props.onSubmit(formData)
99
+ } catch (error) {
100
+ formService.submitError.setValue(error)
101
+ } finally {
102
+ formService.isSubmitting.setValue(false)
103
+ if (props.disableOnSubmit) {
104
+ formElement.inert = false
105
+ }
106
+ }
84
107
  }
85
108
  } else {
86
109
  formService.validationResult.setValue({ isValid: false, reason: 'validation-failed' })
@@ -89,14 +112,14 @@ export const Form: <T>(props: FormProps<T>, children: ChildrenList) => JSX.Eleme
89
112
 
90
113
  useHostProps({
91
114
  oninvalid: (ev: Event) => {
92
- changeHandler(ev)
115
+ void changeHandler(ev)
93
116
  },
94
117
  onsubmit: (ev: SubmitEvent) => {
95
118
  ev.preventDefault()
96
- changeHandler(ev, true)
119
+ void changeHandler(ev, true)
97
120
  },
98
121
  onchange: (ev: Event) => {
99
- changeHandler(ev)
122
+ void changeHandler(ev)
100
123
  },
101
124
  onreset: () => {
102
125
  formService.rawFormData.setValue(null)
@@ -29,6 +29,7 @@ export * from './inputs/index.js'
29
29
  export * from './linear-progress.js'
30
30
  export * from './list/index.js'
31
31
  export * from './loader.js'
32
+ export * from './markdown/index.js'
32
33
  export * from './menu/index.js'
33
34
  export * from './modal.js'
34
35
  export * from './noty-list.js'
@@ -0,0 +1,4 @@
1
+ export * from './markdown-parser.js'
2
+ export * from './markdown-display.js'
3
+ export * from './markdown-input.js'
4
+ export * from './markdown-editor.js'
@@ -0,0 +1,243 @@
1
+ import { Injector } from '@furystack/inject'
2
+ import { createComponent, initializeShadeRoot } from '@furystack/shades'
3
+ import { sleepAsync, usingAsync } from '@furystack/utils'
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
5
+ import { MarkdownDisplay } from './markdown-display.js'
6
+
7
+ describe('MarkdownDisplay', () => {
8
+ beforeEach(() => {
9
+ document.body.innerHTML = '<div id="root"></div>'
10
+ })
11
+
12
+ afterEach(() => {
13
+ document.body.innerHTML = ''
14
+ vi.restoreAllMocks()
15
+ })
16
+
17
+ it('should render with shadow DOM', async () => {
18
+ await usingAsync(new Injector(), async (injector) => {
19
+ const rootElement = document.getElementById('root') as HTMLDivElement
20
+
21
+ initializeShadeRoot({
22
+ injector,
23
+ rootElement,
24
+ jsxElement: <MarkdownDisplay content="Hello" />,
25
+ })
26
+
27
+ await sleepAsync(50)
28
+
29
+ const el = document.querySelector('shade-markdown-display')
30
+ expect(el).not.toBeNull()
31
+ })
32
+ })
33
+
34
+ it('should render a heading', async () => {
35
+ await usingAsync(new Injector(), async (injector) => {
36
+ const rootElement = document.getElementById('root') as HTMLDivElement
37
+
38
+ initializeShadeRoot({
39
+ injector,
40
+ rootElement,
41
+ jsxElement: <MarkdownDisplay content="# Hello World" />,
42
+ })
43
+
44
+ await sleepAsync(50)
45
+
46
+ const typography = document.querySelector('shade-markdown-display shade-typography')
47
+ expect(typography).not.toBeNull()
48
+ expect(typography?.getAttribute('data-variant')).toBe('h1')
49
+ expect(typography?.textContent).toContain('Hello World')
50
+ })
51
+ })
52
+
53
+ it('should render a paragraph', async () => {
54
+ await usingAsync(new Injector(), async (injector) => {
55
+ const rootElement = document.getElementById('root') as HTMLDivElement
56
+
57
+ initializeShadeRoot({
58
+ injector,
59
+ rootElement,
60
+ jsxElement: <MarkdownDisplay content="Just a paragraph." />,
61
+ })
62
+
63
+ await sleepAsync(50)
64
+
65
+ const typography = document.querySelector('shade-markdown-display shade-typography[data-variant="body1"]')
66
+ expect(typography).not.toBeNull()
67
+ expect(typography?.textContent).toContain('Just a paragraph.')
68
+ })
69
+ })
70
+
71
+ it('should render a code block', async () => {
72
+ await usingAsync(new Injector(), async (injector) => {
73
+ const rootElement = document.getElementById('root') as HTMLDivElement
74
+
75
+ initializeShadeRoot({
76
+ injector,
77
+ rootElement,
78
+ jsxElement: <MarkdownDisplay content={'```js\nconsole.log("hi")\n```'} />,
79
+ })
80
+
81
+ await sleepAsync(50)
82
+
83
+ const codeBlock = document.querySelector('shade-markdown-display .md-code-block')
84
+ expect(codeBlock).not.toBeNull()
85
+ expect(codeBlock?.textContent).toContain('console.log("hi")')
86
+ })
87
+ })
88
+
89
+ it('should render a list', async () => {
90
+ await usingAsync(new Injector(), async (injector) => {
91
+ const rootElement = document.getElementById('root') as HTMLDivElement
92
+
93
+ initializeShadeRoot({
94
+ injector,
95
+ rootElement,
96
+ jsxElement: <MarkdownDisplay content={'- Item A\n- Item B'} />,
97
+ })
98
+
99
+ await sleepAsync(50)
100
+
101
+ const list = document.querySelector('shade-markdown-display ul')
102
+ expect(list).not.toBeNull()
103
+ const items = document.querySelectorAll('shade-markdown-display .md-list-item')
104
+ expect(items.length).toBe(2)
105
+ })
106
+ })
107
+
108
+ it('should render checkboxes as disabled when readOnly (default)', async () => {
109
+ await usingAsync(new Injector(), async (injector) => {
110
+ const rootElement = document.getElementById('root') as HTMLDivElement
111
+
112
+ initializeShadeRoot({
113
+ injector,
114
+ rootElement,
115
+ jsxElement: <MarkdownDisplay content="- [ ] Task" />,
116
+ })
117
+
118
+ await sleepAsync(50)
119
+
120
+ const checkbox = document.querySelector('shade-markdown-display shade-checkbox')
121
+ expect(checkbox).not.toBeNull()
122
+ expect(checkbox?.hasAttribute('data-disabled')).toBe(true)
123
+ })
124
+ })
125
+
126
+ it('should render checkboxes as enabled when readOnly is false', async () => {
127
+ await usingAsync(new Injector(), async (injector) => {
128
+ const rootElement = document.getElementById('root') as HTMLDivElement
129
+ const onChange = vi.fn()
130
+
131
+ initializeShadeRoot({
132
+ injector,
133
+ rootElement,
134
+ jsxElement: <MarkdownDisplay content="- [ ] Task" readOnly={false} onChange={onChange} />,
135
+ })
136
+
137
+ await sleepAsync(50)
138
+
139
+ const checkbox = document.querySelector('shade-markdown-display shade-checkbox')
140
+ expect(checkbox).not.toBeNull()
141
+ expect(checkbox?.hasAttribute('data-disabled')).toBe(false)
142
+
143
+ const input = checkbox?.querySelector('input[type="checkbox"]') as HTMLInputElement
144
+ expect(input).not.toBeNull()
145
+ input.click()
146
+
147
+ await sleepAsync(50)
148
+
149
+ expect(onChange).toHaveBeenCalledOnce()
150
+ expect(onChange).toHaveBeenCalledWith('- [x] Task')
151
+ })
152
+ })
153
+
154
+ it('should render a blockquote', async () => {
155
+ await usingAsync(new Injector(), async (injector) => {
156
+ const rootElement = document.getElementById('root') as HTMLDivElement
157
+
158
+ initializeShadeRoot({
159
+ injector,
160
+ rootElement,
161
+ jsxElement: <MarkdownDisplay content="> Quote text" />,
162
+ })
163
+
164
+ await sleepAsync(50)
165
+
166
+ const bq = document.querySelector('shade-markdown-display .md-blockquote')
167
+ expect(bq).not.toBeNull()
168
+ expect(bq?.textContent).toContain('Quote text')
169
+ })
170
+ })
171
+
172
+ it('should render a horizontal rule', async () => {
173
+ await usingAsync(new Injector(), async (injector) => {
174
+ const rootElement = document.getElementById('root') as HTMLDivElement
175
+
176
+ initializeShadeRoot({
177
+ injector,
178
+ rootElement,
179
+ jsxElement: <MarkdownDisplay content="---" />,
180
+ })
181
+
182
+ await sleepAsync(50)
183
+
184
+ const hr = document.querySelector('shade-markdown-display .md-hr')
185
+ expect(hr).not.toBeNull()
186
+ })
187
+ })
188
+
189
+ it('should render links', async () => {
190
+ await usingAsync(new Injector(), async (injector) => {
191
+ const rootElement = document.getElementById('root') as HTMLDivElement
192
+
193
+ initializeShadeRoot({
194
+ injector,
195
+ rootElement,
196
+ jsxElement: <MarkdownDisplay content="[Click here](https://example.com)" />,
197
+ })
198
+
199
+ await sleepAsync(50)
200
+
201
+ const link = document.querySelector('shade-markdown-display .md-link') as HTMLAnchorElement
202
+ expect(link).not.toBeNull()
203
+ expect(link?.href).toContain('example.com')
204
+ expect(link?.textContent).toContain('Click here')
205
+ })
206
+ })
207
+
208
+ it('should render images', async () => {
209
+ await usingAsync(new Injector(), async (injector) => {
210
+ const rootElement = document.getElementById('root') as HTMLDivElement
211
+
212
+ initializeShadeRoot({
213
+ injector,
214
+ rootElement,
215
+ jsxElement: <MarkdownDisplay content="![alt text](image.png)" />,
216
+ })
217
+
218
+ await sleepAsync(50)
219
+
220
+ const img = document.querySelector('shade-markdown-display .md-image') as HTMLImageElement
221
+ expect(img).not.toBeNull()
222
+ expect(img?.alt).toBe('alt text')
223
+ })
224
+ })
225
+
226
+ it('should render empty for empty content', async () => {
227
+ await usingAsync(new Injector(), async (injector) => {
228
+ const rootElement = document.getElementById('root') as HTMLDivElement
229
+
230
+ initializeShadeRoot({
231
+ injector,
232
+ rootElement,
233
+ jsxElement: <MarkdownDisplay content="" />,
234
+ })
235
+
236
+ await sleepAsync(50)
237
+
238
+ const root = document.querySelector('shade-markdown-display .md-root')
239
+ expect(root).not.toBeNull()
240
+ expect(root?.children.length).toBe(0)
241
+ })
242
+ })
243
+ })