@stack-spot/portal-components 2.27.0 → 2.27.2

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 (250) hide show
  1. package/CHANGELOG.md +635 -621
  2. package/dist/components/AnimatedHeight.d.ts +1 -1
  3. package/dist/components/AnimatedHeight.js +26 -26
  4. package/dist/components/AsyncContent.d.ts +1 -1
  5. package/dist/components/AsyncContent.js +1 -1
  6. package/dist/components/BannerWarning.d.ts +1 -1
  7. package/dist/components/BannerWarning.js +1 -1
  8. package/dist/components/Breadcrumb/index.d.ts +2 -2
  9. package/dist/components/Breadcrumb/index.js +1 -1
  10. package/dist/components/Breadcrumb/styled.js +31 -31
  11. package/dist/components/ButtonLoading.d.ts +1 -1
  12. package/dist/components/ButtonLoading.js +1 -1
  13. package/dist/components/ChatBot.d.ts +1 -1
  14. package/dist/components/ChatBot.js +1 -1
  15. package/dist/components/ContentValidateFilter.d.ts +1 -1
  16. package/dist/components/ContentValidateFilter.js +1 -1
  17. package/dist/components/FadingOverflow.d.ts +1 -1
  18. package/dist/components/FadingOverflow.js +69 -69
  19. package/dist/components/FileTreeView/More.d.ts +1 -1
  20. package/dist/components/FileTreeView/More.js +1 -1
  21. package/dist/components/FileTreeView/index.d.ts +1 -1
  22. package/dist/components/FileTreeView/index.js +1 -1
  23. package/dist/components/InfiniteScroll.d.ts +1 -1
  24. package/dist/components/InfiniteScroll.js +1 -1
  25. package/dist/components/InfoMaintenanceBanner.d.ts +1 -1
  26. package/dist/components/InfoMaintenanceBanner.js +2 -2
  27. package/dist/components/LazyMarkdown/BlockquoteMd.d.ts +1 -1
  28. package/dist/components/LazyMarkdown/BlockquoteMd.js +1 -1
  29. package/dist/components/LazyMarkdown/CodeViewer.d.ts +1 -1
  30. package/dist/components/LazyMarkdown/CodeViewer.js +76 -76
  31. package/dist/components/LazyMarkdown/Markdown.d.ts +1 -1
  32. package/dist/components/LazyMarkdown/Markdown.js +1 -1
  33. package/dist/components/LazyMarkdown/MarkdownButton.d.ts +1 -1
  34. package/dist/components/LazyMarkdown/MarkdownButton.js +1 -1
  35. package/dist/components/LazyMarkdown/Video.d.ts +1 -1
  36. package/dist/components/LazyMarkdown/Video.js +1 -1
  37. package/dist/components/LazyMarkdown/index.d.ts +1 -1
  38. package/dist/components/LazyMarkdown/index.js +1 -1
  39. package/dist/components/Placeholder.d.ts +7 -3
  40. package/dist/components/Placeholder.d.ts.map +1 -1
  41. package/dist/components/Placeholder.js +3 -3
  42. package/dist/components/Placeholder.js.map +1 -1
  43. package/dist/components/ScrollView.js +16 -16
  44. package/dist/components/Select/BadgeItem.d.ts +1 -1
  45. package/dist/components/Select/BadgeItem.js +1 -1
  46. package/dist/components/Select/ClearInput.d.ts +1 -1
  47. package/dist/components/Select/ClearInput.js +1 -1
  48. package/dist/components/Select/CloseItem.d.ts +1 -1
  49. package/dist/components/Select/CloseItem.js +1 -1
  50. package/dist/components/Select/CreatableSelect.js +1 -1
  51. package/dist/components/Select/CustomMenu.d.ts +1 -1
  52. package/dist/components/Select/CustomMenu.js +1 -1
  53. package/dist/components/Select/LabelItem.d.ts +1 -1
  54. package/dist/components/Select/LabelItem.js +1 -1
  55. package/dist/components/Select/MultiValue.d.ts +1 -1
  56. package/dist/components/Select/MultiValue.js +1 -1
  57. package/dist/components/Select/SelectInfiniteScroll.d.ts +1 -1
  58. package/dist/components/Select/SelectInfiniteScroll.js +1 -1
  59. package/dist/components/Select/SelectSearch.d.ts +1 -1
  60. package/dist/components/Select/SelectSearch.js +1 -1
  61. package/dist/components/SelectionList.d.ts +1 -1
  62. package/dist/components/SelectionList.js +61 -61
  63. package/dist/components/StatusCircle.d.ts +1 -1
  64. package/dist/components/StatusCircle.js +6 -6
  65. package/dist/components/Stepper/Navigation.js +4 -4
  66. package/dist/components/Stepper/Step.js +3 -3
  67. package/dist/components/Stepper/Stepper.js +6 -6
  68. package/dist/components/Stepper/headers.js +22 -22
  69. package/dist/components/Table/HeaderItem.js +1 -1
  70. package/dist/components/Table/SettingsVerticalMenu.d.ts +1 -1
  71. package/dist/components/Table/SettingsVerticalMenu.js +1 -1
  72. package/dist/components/Table/StyledLinkTable.d.ts +1 -1
  73. package/dist/components/Table/StyledLinkTable.js +5 -5
  74. package/dist/components/Table/TableData.d.ts +1 -1
  75. package/dist/components/Table/TableData.js +25 -25
  76. package/dist/components/TimelineSection.d.ts +1 -1
  77. package/dist/components/TimelineSection.js +14 -14
  78. package/dist/components/error/ErrorFeedback.d.ts +1 -1
  79. package/dist/components/error/ErrorFeedback.js +35 -35
  80. package/dist/components/error/NotFound.d.ts +1 -1
  81. package/dist/components/error/NotFound.js +1 -1
  82. package/dist/components/error/UnderMaintenance.d.ts +1 -1
  83. package/dist/components/error/UnderMaintenance.js +1 -1
  84. package/dist/components/form/Form/Form.d.ts +1 -1
  85. package/dist/components/form/Form/Form.js +1 -1
  86. package/dist/components/form/Form/FormGroup.d.ts +2 -2
  87. package/dist/components/form/Form/FormGroup.js +1 -1
  88. package/dist/components/form/SearchInput.d.ts +1 -1
  89. package/dist/components/form/SearchInput.js +1 -1
  90. package/dist/components/form/Select/CustomSelect.d.ts +1 -1
  91. package/dist/components/form/Select/CustomSelect.js +1 -1
  92. package/dist/components/form/Select/DetailedSelect.d.ts +1 -1
  93. package/dist/components/form/Select/DetailedSelect.js +1 -1
  94. package/dist/components/form/Select/Select.d.ts +1 -1
  95. package/dist/components/form/Select/Select.js +1 -1
  96. package/dist/components/form/Select/styled.js +161 -161
  97. package/dist/components/form/Select/utils.js +1 -1
  98. package/dist/components/notification/NotificationComponent.d.ts +1 -1
  99. package/dist/components/notification/NotificationComponent.js +54 -54
  100. package/dist/components/notification/NotificationItem.d.ts +1 -1
  101. package/dist/components/notification/NotificationItem.d.ts.map +1 -1
  102. package/dist/components/notification/NotificationItem.js +11 -5
  103. package/dist/components/notification/NotificationItem.js.map +1 -1
  104. package/dist/components/notification/NotificationList.d.ts +1 -1
  105. package/dist/components/notification/NotificationList.d.ts.map +1 -1
  106. package/dist/components/notification/NotificationList.js +44 -44
  107. package/dist/components/notification/NotificationList.js.map +1 -1
  108. package/dist/components/notification/NotificationPlaceholder.d.ts +1 -1
  109. package/dist/components/notification/NotificationPlaceholder.d.ts.map +1 -1
  110. package/dist/components/notification/NotificationPlaceholder.js +2 -2
  111. package/dist/components/notification/NotificationPlaceholder.js.map +1 -1
  112. package/dist/containers/NotificationsPage.d.ts +1 -1
  113. package/dist/containers/NotificationsPage.d.ts.map +1 -1
  114. package/dist/containers/NotificationsPage.js +24 -11
  115. package/dist/containers/NotificationsPage.js.map +1 -1
  116. package/dist/context/anchor.d.ts +1 -1
  117. package/dist/context/anchor.js +1 -1
  118. package/dist/context/loading.d.ts +1 -1
  119. package/dist/context/loading.js +1 -1
  120. package/dist/context/notification/context.d.ts +1 -1
  121. package/dist/context/notification/context.js +1 -1
  122. package/dist/context/notification/types.d.ts +1 -0
  123. package/dist/context/notification/types.d.ts.map +1 -1
  124. package/dist/hooks/date.js +1 -1
  125. package/dist/hooks/service-now.js +28 -28
  126. package/dist/svg/AI.d.ts +1 -1
  127. package/dist/svg/AI.js +1 -1
  128. package/dist/svg/CS.d.ts +1 -1
  129. package/dist/svg/CS.js +1 -1
  130. package/dist/svg/EDP.d.ts +1 -1
  131. package/dist/svg/EDP.js +1 -1
  132. package/dist/svg/Forbidden.d.ts +1 -1
  133. package/dist/svg/Forbidden.js +1 -1
  134. package/dist/svg/GenericPlaceholder.d.ts +4 -2
  135. package/dist/svg/GenericPlaceholder.d.ts.map +1 -1
  136. package/dist/svg/GenericPlaceholder.js +2 -2
  137. package/dist/svg/GenericPlaceholder.js.map +1 -1
  138. package/dist/svg/HUB.d.ts +1 -1
  139. package/dist/svg/HUB.js +1 -1
  140. package/dist/svg/Logo.d.ts +1 -1
  141. package/dist/svg/Logo.js +1 -1
  142. package/dist/svg/MiniLogo.d.ts +1 -1
  143. package/dist/svg/MiniLogo.js +1 -1
  144. package/dist/svg/NotFound.d.ts +1 -1
  145. package/dist/svg/NotFound.js +1 -1
  146. package/dist/svg/ServerError.d.ts +1 -1
  147. package/dist/svg/ServerError.js +1 -1
  148. package/dist/svg/Unauthenticated.d.ts +1 -1
  149. package/dist/svg/Unauthenticated.js +1 -1
  150. package/package.json +6 -6
  151. package/readme.md +66 -66
  152. package/src/components/AnimatedHeight.tsx +174 -174
  153. package/src/components/AsyncContent.tsx +78 -78
  154. package/src/components/BannerWarning.tsx +91 -91
  155. package/src/components/Breadcrumb/index.tsx +76 -76
  156. package/src/components/Breadcrumb/styled.ts +37 -37
  157. package/src/components/ButtonLoading.tsx +29 -29
  158. package/src/components/ChatBot.tsx +82 -82
  159. package/src/components/ContentValidateFilter.tsx +15 -15
  160. package/src/components/FadingOverflow.tsx +265 -265
  161. package/src/components/FileTreeView/More.tsx +114 -114
  162. package/src/components/FileTreeView/index.tsx +186 -186
  163. package/src/components/InfiniteScroll.tsx +24 -24
  164. package/src/components/InfoMaintenanceBanner.tsx +29 -29
  165. package/src/components/LazyMarkdown/BlockquoteMd.tsx +107 -107
  166. package/src/components/LazyMarkdown/CodeViewer.tsx +161 -161
  167. package/src/components/LazyMarkdown/Markdown.tsx +122 -122
  168. package/src/components/LazyMarkdown/MarkdownButton.tsx +24 -24
  169. package/src/components/LazyMarkdown/Video.tsx +13 -13
  170. package/src/components/LazyMarkdown/index.tsx +21 -21
  171. package/src/components/Placeholder.tsx +123 -118
  172. package/src/components/ScrollView.tsx +57 -57
  173. package/src/components/Select/BadgeItem.tsx +58 -58
  174. package/src/components/Select/ClearInput.tsx +24 -24
  175. package/src/components/Select/CloseItem.tsx +38 -38
  176. package/src/components/Select/CreatableSelect.tsx +155 -155
  177. package/src/components/Select/CustomMenu.tsx +16 -16
  178. package/src/components/Select/LabelItem.tsx +8 -8
  179. package/src/components/Select/MultiValue.tsx +49 -49
  180. package/src/components/Select/SelectInfiniteScroll.tsx +82 -82
  181. package/src/components/Select/SelectSearch.tsx +195 -195
  182. package/src/components/Select/index.tsx +7 -7
  183. package/src/components/Select/types.ts +8 -8
  184. package/src/components/SelectionList.tsx +427 -427
  185. package/src/components/StatusCircle.tsx +67 -67
  186. package/src/components/Stepper/Navigation.tsx +97 -97
  187. package/src/components/Stepper/Step.tsx +30 -30
  188. package/src/components/Stepper/Stepper.tsx +113 -113
  189. package/src/components/Stepper/headers.tsx +64 -64
  190. package/src/components/Stepper/index.ts +3 -3
  191. package/src/components/Table/HeaderItem.tsx +52 -52
  192. package/src/components/Table/SettingsVerticalMenu.tsx +50 -50
  193. package/src/components/Table/StyledLinkTable.tsx +22 -22
  194. package/src/components/Table/TableData.tsx +251 -251
  195. package/src/components/Table/index.tsx +2 -2
  196. package/src/components/TimelineSection.tsx +66 -66
  197. package/src/components/error/ErrorFeedback.tsx +217 -217
  198. package/src/components/error/NotFound.tsx +24 -24
  199. package/src/components/error/UnderMaintenance.tsx +30 -30
  200. package/src/components/error/index.ts +4 -4
  201. package/src/components/form/Form/Form.tsx +101 -101
  202. package/src/components/form/Form/FormGroup.tsx +221 -221
  203. package/src/components/form/Form/index.ts +2 -2
  204. package/src/components/form/SearchInput.tsx +69 -69
  205. package/src/components/form/Select/CustomSelect.tsx +232 -232
  206. package/src/components/form/Select/DetailedSelect.tsx +85 -85
  207. package/src/components/form/Select/Select.tsx +67 -67
  208. package/src/components/form/Select/index.ts +4 -4
  209. package/src/components/form/Select/styled.ts +165 -165
  210. package/src/components/form/Select/types.ts +112 -112
  211. package/src/components/form/Select/utils.tsx +28 -28
  212. package/src/components/notification/NotificationComponent.tsx +340 -340
  213. package/src/components/notification/NotificationItem.tsx +345 -336
  214. package/src/components/notification/NotificationList.tsx +179 -178
  215. package/src/components/notification/NotificationPlaceholder.tsx +44 -43
  216. package/src/components/notification/types.ts +72 -72
  217. package/src/containers/NotificationsPage.tsx +119 -98
  218. package/src/context/anchor.tsx +37 -37
  219. package/src/context/loading.tsx +36 -36
  220. package/src/context/notification/LazyNotificationList.ts +103 -103
  221. package/src/context/notification/NotificationController.ts +104 -104
  222. package/src/context/notification/context.tsx +23 -23
  223. package/src/context/notification/hooks.ts +98 -98
  224. package/src/context/notification/types.ts +66 -65
  225. package/src/hooks/date.ts +31 -31
  226. package/src/hooks/keyboard.tsx +128 -128
  227. package/src/hooks/manual-render.tsx +10 -10
  228. package/src/hooks/service-now.tsx +233 -233
  229. package/src/hooks/text.tsx +30 -30
  230. package/src/hooks/title.tsx +28 -28
  231. package/src/hooks/use-effect-once.tsx +43 -43
  232. package/src/index.ts +19 -19
  233. package/src/notifications.ts +11 -11
  234. package/src/svg/AI.tsx +41 -41
  235. package/src/svg/CS.tsx +48 -48
  236. package/src/svg/EDP.tsx +31 -31
  237. package/src/svg/Forbidden.tsx +22 -22
  238. package/src/svg/GenericPlaceholder.tsx +20 -20
  239. package/src/svg/HUB.tsx +48 -48
  240. package/src/svg/Logo.tsx +16 -16
  241. package/src/svg/MiniLogo.tsx +12 -12
  242. package/src/svg/NotFound.tsx +16 -16
  243. package/src/svg/ServerError.tsx +33 -33
  244. package/src/svg/Unauthenticated.tsx +16 -16
  245. package/src/svg/index.ts +11 -11
  246. package/src/utils/accessibility.ts +135 -135
  247. package/src/utils/cookie.ts +73 -73
  248. package/src/utils/promise.ts +5 -5
  249. package/src/utils/read-file.ts +16 -16
  250. package/tsconfig.json +10 -10
@@ -1,69 +1,69 @@
1
- import { Box, IconBox, Input } from '@citric/core'
2
- import { Filter, Times } from '@citric/icons'
3
- import { FieldAddon, FieldGroup, IconButton } from '@citric/ui'
4
- import { Dictionary, useTranslate } from '@stack-spot/portal-translate'
5
- import { debounce } from 'lodash'
6
- import { useCallback, useState } from 'react'
7
-
8
- export const SearchInput = ({
9
- searchText,
10
- defaultValue,
11
- disabled = false,
12
- onChange,
13
- style,
14
- className,
15
- }: {
16
- searchText: string,
17
- defaultValue?: string,
18
- disabled?: boolean,
19
- onChange: (value?: string) => void,
20
- style?: React.CSSProperties,
21
- className?: string,
22
- }) => {
23
- const [value, setValue] = useState(defaultValue)
24
- const runOnChange = useCallback(debounce(onChange, 800), [onChange])
25
- const t = useTranslate(dictionary)
26
-
27
- return (
28
- <Box sx={{ position: 'relative' }} style={style} className={className}>
29
- <FieldGroup>
30
- <FieldAddon>
31
- <IconBox size="xs" colorIcon="light.700">
32
- <Filter />
33
- </IconBox>
34
- </FieldAddon>
35
- <Input
36
- value={value}
37
- placeholder={searchText}
38
- onChange={(e) => {
39
- setValue(e.target.value)
40
- runOnChange(e.target.value)
41
- }}
42
- disabled={disabled}
43
- maxLength={255}
44
- />
45
- </FieldGroup>
46
- {!!value && (
47
- <IconButton
48
- sx={{ position: 'absolute', right: '20px', top: '50%', transform: 'translate(50%, -50%)' }}
49
- onClick={() => {
50
- setValue('')
51
- runOnChange('')
52
- }}
53
- aria-label={t.ariaClearField}
54
- >
55
- <Times />
56
- </IconButton>
57
- )}
58
- </Box>
59
- )
60
- }
61
-
62
- const dictionary = {
63
- en: {
64
- ariaClearField: 'Clear field',
65
- },
66
- pt: {
67
- ariaClearField: 'Limpar campo',
68
- },
69
- } satisfies Dictionary
1
+ import { Box, IconBox, Input } from '@citric/core'
2
+ import { Filter, Times } from '@citric/icons'
3
+ import { FieldAddon, FieldGroup, IconButton } from '@citric/ui'
4
+ import { Dictionary, useTranslate } from '@stack-spot/portal-translate'
5
+ import { debounce } from 'lodash'
6
+ import { useCallback, useState } from 'react'
7
+
8
+ export const SearchInput = ({
9
+ searchText,
10
+ defaultValue,
11
+ disabled = false,
12
+ onChange,
13
+ style,
14
+ className,
15
+ }: {
16
+ searchText: string,
17
+ defaultValue?: string,
18
+ disabled?: boolean,
19
+ onChange: (value?: string) => void,
20
+ style?: React.CSSProperties,
21
+ className?: string,
22
+ }) => {
23
+ const [value, setValue] = useState(defaultValue)
24
+ const runOnChange = useCallback(debounce(onChange, 800), [onChange])
25
+ const t = useTranslate(dictionary)
26
+
27
+ return (
28
+ <Box sx={{ position: 'relative' }} style={style} className={className}>
29
+ <FieldGroup>
30
+ <FieldAddon>
31
+ <IconBox size="xs" colorIcon="light.700">
32
+ <Filter />
33
+ </IconBox>
34
+ </FieldAddon>
35
+ <Input
36
+ value={value}
37
+ placeholder={searchText}
38
+ onChange={(e) => {
39
+ setValue(e.target.value)
40
+ runOnChange(e.target.value)
41
+ }}
42
+ disabled={disabled}
43
+ maxLength={255}
44
+ />
45
+ </FieldGroup>
46
+ {!!value && (
47
+ <IconButton
48
+ sx={{ position: 'absolute', right: '20px', top: '50%', transform: 'translate(50%, -50%)' }}
49
+ onClick={() => {
50
+ setValue('')
51
+ runOnChange('')
52
+ }}
53
+ aria-label={t.ariaClearField}
54
+ >
55
+ <Times />
56
+ </IconButton>
57
+ )}
58
+ </Box>
59
+ )
60
+ }
61
+
62
+ const dictionary = {
63
+ en: {
64
+ ariaClearField: 'Clear field',
65
+ },
66
+ pt: {
67
+ ariaClearField: 'Limpar campo',
68
+ },
69
+ } satisfies Dictionary
@@ -1,232 +1,232 @@
1
- import { IconBox } from '@citric/core'
2
- import { ChevronDown } from '@citric/icons'
3
- import { LoadingCircular } from '@citric/ui'
4
- import { listToClass } from '@stack-spot/portal-theme'
5
- import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
6
- import { delay } from '../../../utils/promise'
7
- import { SelectBox } from './styled'
8
- import { CustomSelectProps, GenericAccessibleLabel, KeyOfType } from './types'
9
- import { parseLabel } from './utils'
10
-
11
- function getOptionAsValue<Option, T extends KeyOfType<Option, string> | ((o: NonNullable<Option>) => string)>(
12
- option: Option,
13
- renderer: T | undefined,
14
- ): string {
15
- let result: string | undefined
16
- if (typeof renderer === 'function') result = option ? renderer(option) : undefined
17
- else if (typeof renderer === 'string') result = option[renderer as keyof Option] as string
18
- return result ? result : `${option ?? ''}`
19
- }
20
-
21
- function getOptionAsLabel<
22
- Option,
23
- T extends KeyOfType<Option, GenericAccessibleLabel> | ((o: NonNullable<Option>) => GenericAccessibleLabel)
24
- >(
25
- option: Option,
26
- renderer: T | undefined,
27
- ): GenericAccessibleLabel {
28
- let result: GenericAccessibleLabel | undefined
29
- if (typeof renderer === 'function') result = option ? renderer(option) : undefined
30
- else if (typeof renderer === 'string') result = option[renderer as keyof Option] as GenericAccessibleLabel
31
- return result ? result : parseLabel(`${option ?? ''}`)
32
- }
33
-
34
- const FakeOption = (
35
- { value, label, onChange }: { value: string, label: React.ReactElement, onChange: (event: { target: { value: string } }) => void },
36
- ) => (
37
- <li className="option" onClick={() => onChange({ target: { value } })}>
38
- {label}
39
- </li>
40
- )
41
-
42
- /**
43
- * Renders a Select component using the Citric Design System.
44
- *
45
- * The styled version of the select component is rendered on top of the default select from the browser. Visual users will use the Citric
46
- * version of a Select, but blind users, who interacts with the keyboard, will use the default browser select instead, which is already
47
- * highly optimized for accessibility.
48
- *
49
- * The CustomSelect lets you customize how each option and the selected value are rendered. To do so, use the prop `renderLabel` and
50
- * `emptyOption`.
51
- *
52
- * If you don't need fully customized labels, check the more simple components: `DetailedSelect` and `Select`.
53
- *
54
- * The CustomSelect expects a {@link GenericAccessibleLabel} to create labels.
55
- *
56
- * Tips:
57
- * - This is a controlled field. You can't use it any other way. If you're using it with react-hook-form, you need to wrap it under the
58
- * component `<Controller>` from the same library.
59
- * - `value` is required and must be of the same type of an item of the array of options. `value` is only optional if `emptyOption` is
60
- * provided, in this case, an empty option is rendered and the value is undefined when it's selected.
61
- * - A consequence of the previous rule is that you can't have an empty selection if you don't set a value for `emptyOption`. This
62
- * component must work exactly like the browser's `select`, so this behavior is intended.
63
- * - If `renderLabel` or `renderValue` are not provided, this will use the `toString` method of the object.
64
- *
65
- * @example
66
- * options as an object array
67
- * ```
68
- * const options = ['option 1', 'option 2', 'option 3']
69
- *
70
- * function renderCustomLabel(option: string) {
71
- * return {
72
- * // this is how the option will be rendered in the list
73
- * option: (
74
- * <div style={{ display: flex, flexDirection: 'row', gap: '5px' }}>
75
- * <img src="/my-image.png" width="40px" height="40px" />
76
- * <p>An option called {option}</p>
77
- * </div>
78
- * ),
79
- * // this is how the option will be rendered inside the input, when it's the value currently selected.
80
- * selected: <p>{option}</p>,
81
- * // this a string representation of the option: used for accessibility. This should contain the same information as `option`.
82
- * text: `An option called ${option}`,
83
- * )
84
- * }
85
- *
86
- * const MyComponent = {
87
- * const [value, setValue] = useState(options[0])
88
- * return <CustomSelect options={options} value={value} onChange={setValue} renderLabel={renderCustomLabel} />
89
- * }
90
- * ```
91
- * @example
92
- * options as an object array
93
- * ```
94
- * const options = [{ id: 1, name: 'John', age: 34 }, { id: 2, name: 'Marcia', age: 28 }, { id: 3, name: 'Angeline', age: 58 }]
95
- *
96
- * function renderCustomLabel(option: (typeof options)[number]) {
97
- * return {
98
- * // this is how the option will be rendered in the list
99
- * option: (
100
- * <div style={{ display: flex, flexDirection: 'row', gap: '5px' }}>
101
- * <img src="/my-image.png" width="40px" height="40px" />
102
- * <p>{option.name}, aged {option.age}</p>
103
- * </div>
104
- * ),
105
- * // this is how the option will be rendered inside the input, when it's the value currently selected.
106
- * selected: <p>{option}</p>,
107
- * // this a string representation of the option: used for accessibility. This should contain the same information as `option`.
108
- * text: `${option.name}, aged ${option.age}`,
109
- * )
110
- * }
111
- *
112
- * const MyComponent = {
113
- * const [value, setValue] = useState(options[0])
114
- * // below, renderValue could be `o => o.id`
115
- * return <CustomSelect options={options} value={value} onChange={setValue} renderValue="id" renderLabel={renderCustomLabel} />
116
- * }
117
- * ```
118
- * @param props the component props: {@link CustomSelectProps}.
119
- */
120
- export function CustomSelect<T>({
121
- onChange, options, value, emptyOption, renderLabel, renderValue, maxItems = 6, onFocus, onBlur, style, className, isLoading, disabled,
122
- height = '42px', ...props
123
- }: CustomSelectProps<T>) {
124
- const [open, setOpen] = useState(false)
125
- const [focused, setFocused] = useState(false)
126
- const fakeSelectRef = useRef<HTMLDivElement>(null)
127
- const listHeight = useRef(0)
128
- const isDisabled = disabled || isLoading
129
- const isMeasuring = useRef(false)
130
-
131
- const onChangeOption = useCallback((event: { target: { value: string } }) => {
132
- const value = options?.find(o => getOptionAsValue(o, renderValue) === event.target.value)
133
- onChange(value!)
134
- setOpen(false)
135
- }, [options])
136
-
137
- const onClickOutside = useCallback((event: MouseEvent) => {
138
- if (fakeSelectRef.current && !fakeSelectRef.current.contains(event.target as Node)) setOpen(false)
139
- }, [])
140
-
141
- const [htmlOptions, fakeOptions] = useMemo(
142
- () => (options ?? []).reduce<[React.ReactElement[], React.ReactElement[]]>(([opts, fake], o) => {
143
- const id = getOptionAsValue(o, renderValue)
144
- const label = getOptionAsLabel(o, renderLabel)
145
- return [
146
- [...opts, <option key={id} value={id} selected={value === id}>{label.text}</option>],
147
- [...fake, <FakeOption key={id} value={id} label={label.option} onChange={onChangeOption} />],
148
- ]
149
- }, [[], []]),
150
- [options, value],
151
- )
152
-
153
- function getCurrentValue() {
154
- return value === undefined ? '' : getOptionAsValue(value, renderValue)
155
- }
156
-
157
- function getCurrentLabel() {
158
- return value === undefined ? emptyOption : getOptionAsLabel(value, renderLabel)
159
- }
160
-
161
- useEffect(() => {
162
- const detach = () => document.removeEventListener('mousedown', onClickOutside)
163
- if (open) document.addEventListener('mousedown', onClickOutside)
164
- else detach()
165
- return detach
166
- }, [open])
167
-
168
- useEffect(() => {
169
- async function measure() {
170
- // semaphore for controlling concurrence
171
- if (isMeasuring.current) return
172
- isMeasuring.current = true
173
- const list = fakeSelectRef.current?.querySelector('.options')
174
- if (!list) return
175
- list.setAttribute('inert', '')
176
- list.setAttribute('style', 'height: auto')
177
- await delay(0)
178
- listHeight.current = list.clientHeight
179
- await delay(0)
180
- list.setAttribute('style', `height: ${open ? listHeight.current : 0}`)
181
- list.removeAttribute('inert')
182
- isMeasuring.current = false
183
- }
184
- measure()
185
- }, [options, fakeSelectRef.current])
186
-
187
- // replicates the original select effect of the browser to select the first option. This is necessary because we use the default
188
- // select element for accessibility, the behavior must not differ.
189
- useEffect(() => {
190
- if (!value && !emptyOption && options?.length) {
191
- onChange(options[0])
192
- }
193
- }, [options])
194
-
195
- return (
196
- <SelectBox style={style} className={className} $maxItems={maxItems} $inputHeight={height}>
197
- { /* Screen readers can use the select component from the browser instead of the highly styled component we show. */ }
198
- <select
199
- {...props}
200
- value={getCurrentValue()}
201
- onChange={onChangeOption}
202
- onFocus={(ev) => {
203
- setFocused(true)
204
- onFocus?.(ev)
205
- }}
206
- onBlur={(ev) => {
207
- setFocused(false)
208
- onBlur?.(ev)
209
- }}
210
- disabled={isDisabled}
211
- aria-busy={isLoading}
212
- >
213
- {emptyOption === undefined ? null : <option value="" selected={!value}>{emptyOption.text}</option>}
214
- {htmlOptions}
215
- </select>
216
- <div
217
- ref={fakeSelectRef}
218
- className={listToClass(['fake-select', open && 'open', focused && 'focused', isDisabled && 'disabled'])}
219
- aria-hidden
220
- >
221
- <div className="current-value" onClick={isDisabled ? undefined : () => setOpen(!open)}>
222
- {getCurrentLabel()?.selected || getCurrentLabel()?.option || <div></div>}
223
- {isLoading ? <LoadingCircular size="sm" /> : <IconBox className="arrow"><ChevronDown /></IconBox>}
224
- </div>
225
- <ul className="options" style={{ height: open ? listHeight.current : 0 }}>
226
- {emptyOption === undefined ? null : <FakeOption value="" label={emptyOption.option} onChange={onChangeOption} />}
227
- {fakeOptions}
228
- </ul>
229
- </div>
230
- </SelectBox>
231
- )
232
- }
1
+ import { IconBox } from '@citric/core'
2
+ import { ChevronDown } from '@citric/icons'
3
+ import { LoadingCircular } from '@citric/ui'
4
+ import { listToClass } from '@stack-spot/portal-theme'
5
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
6
+ import { delay } from '../../../utils/promise'
7
+ import { SelectBox } from './styled'
8
+ import { CustomSelectProps, GenericAccessibleLabel, KeyOfType } from './types'
9
+ import { parseLabel } from './utils'
10
+
11
+ function getOptionAsValue<Option, T extends KeyOfType<Option, string> | ((o: NonNullable<Option>) => string)>(
12
+ option: Option,
13
+ renderer: T | undefined,
14
+ ): string {
15
+ let result: string | undefined
16
+ if (typeof renderer === 'function') result = option ? renderer(option) : undefined
17
+ else if (typeof renderer === 'string') result = option[renderer as keyof Option] as string
18
+ return result ? result : `${option ?? ''}`
19
+ }
20
+
21
+ function getOptionAsLabel<
22
+ Option,
23
+ T extends KeyOfType<Option, GenericAccessibleLabel> | ((o: NonNullable<Option>) => GenericAccessibleLabel)
24
+ >(
25
+ option: Option,
26
+ renderer: T | undefined,
27
+ ): GenericAccessibleLabel {
28
+ let result: GenericAccessibleLabel | undefined
29
+ if (typeof renderer === 'function') result = option ? renderer(option) : undefined
30
+ else if (typeof renderer === 'string') result = option[renderer as keyof Option] as GenericAccessibleLabel
31
+ return result ? result : parseLabel(`${option ?? ''}`)
32
+ }
33
+
34
+ const FakeOption = (
35
+ { value, label, onChange }: { value: string, label: React.ReactElement, onChange: (event: { target: { value: string } }) => void },
36
+ ) => (
37
+ <li className="option" onClick={() => onChange({ target: { value } })}>
38
+ {label}
39
+ </li>
40
+ )
41
+
42
+ /**
43
+ * Renders a Select component using the Citric Design System.
44
+ *
45
+ * The styled version of the select component is rendered on top of the default select from the browser. Visual users will use the Citric
46
+ * version of a Select, but blind users, who interacts with the keyboard, will use the default browser select instead, which is already
47
+ * highly optimized for accessibility.
48
+ *
49
+ * The CustomSelect lets you customize how each option and the selected value are rendered. To do so, use the prop `renderLabel` and
50
+ * `emptyOption`.
51
+ *
52
+ * If you don't need fully customized labels, check the more simple components: `DetailedSelect` and `Select`.
53
+ *
54
+ * The CustomSelect expects a {@link GenericAccessibleLabel} to create labels.
55
+ *
56
+ * Tips:
57
+ * - This is a controlled field. You can't use it any other way. If you're using it with react-hook-form, you need to wrap it under the
58
+ * component `<Controller>` from the same library.
59
+ * - `value` is required and must be of the same type of an item of the array of options. `value` is only optional if `emptyOption` is
60
+ * provided, in this case, an empty option is rendered and the value is undefined when it's selected.
61
+ * - A consequence of the previous rule is that you can't have an empty selection if you don't set a value for `emptyOption`. This
62
+ * component must work exactly like the browser's `select`, so this behavior is intended.
63
+ * - If `renderLabel` or `renderValue` are not provided, this will use the `toString` method of the object.
64
+ *
65
+ * @example
66
+ * options as an object array
67
+ * ```
68
+ * const options = ['option 1', 'option 2', 'option 3']
69
+ *
70
+ * function renderCustomLabel(option: string) {
71
+ * return {
72
+ * // this is how the option will be rendered in the list
73
+ * option: (
74
+ * <div style={{ display: flex, flexDirection: 'row', gap: '5px' }}>
75
+ * <img src="/my-image.png" width="40px" height="40px" />
76
+ * <p>An option called {option}</p>
77
+ * </div>
78
+ * ),
79
+ * // this is how the option will be rendered inside the input, when it's the value currently selected.
80
+ * selected: <p>{option}</p>,
81
+ * // this a string representation of the option: used for accessibility. This should contain the same information as `option`.
82
+ * text: `An option called ${option}`,
83
+ * )
84
+ * }
85
+ *
86
+ * const MyComponent = {
87
+ * const [value, setValue] = useState(options[0])
88
+ * return <CustomSelect options={options} value={value} onChange={setValue} renderLabel={renderCustomLabel} />
89
+ * }
90
+ * ```
91
+ * @example
92
+ * options as an object array
93
+ * ```
94
+ * const options = [{ id: 1, name: 'John', age: 34 }, { id: 2, name: 'Marcia', age: 28 }, { id: 3, name: 'Angeline', age: 58 }]
95
+ *
96
+ * function renderCustomLabel(option: (typeof options)[number]) {
97
+ * return {
98
+ * // this is how the option will be rendered in the list
99
+ * option: (
100
+ * <div style={{ display: flex, flexDirection: 'row', gap: '5px' }}>
101
+ * <img src="/my-image.png" width="40px" height="40px" />
102
+ * <p>{option.name}, aged {option.age}</p>
103
+ * </div>
104
+ * ),
105
+ * // this is how the option will be rendered inside the input, when it's the value currently selected.
106
+ * selected: <p>{option}</p>,
107
+ * // this a string representation of the option: used for accessibility. This should contain the same information as `option`.
108
+ * text: `${option.name}, aged ${option.age}`,
109
+ * )
110
+ * }
111
+ *
112
+ * const MyComponent = {
113
+ * const [value, setValue] = useState(options[0])
114
+ * // below, renderValue could be `o => o.id`
115
+ * return <CustomSelect options={options} value={value} onChange={setValue} renderValue="id" renderLabel={renderCustomLabel} />
116
+ * }
117
+ * ```
118
+ * @param props the component props: {@link CustomSelectProps}.
119
+ */
120
+ export function CustomSelect<T>({
121
+ onChange, options, value, emptyOption, renderLabel, renderValue, maxItems = 6, onFocus, onBlur, style, className, isLoading, disabled,
122
+ height = '42px', ...props
123
+ }: CustomSelectProps<T>) {
124
+ const [open, setOpen] = useState(false)
125
+ const [focused, setFocused] = useState(false)
126
+ const fakeSelectRef = useRef<HTMLDivElement>(null)
127
+ const listHeight = useRef(0)
128
+ const isDisabled = disabled || isLoading
129
+ const isMeasuring = useRef(false)
130
+
131
+ const onChangeOption = useCallback((event: { target: { value: string } }) => {
132
+ const value = options?.find(o => getOptionAsValue(o, renderValue) === event.target.value)
133
+ onChange(value!)
134
+ setOpen(false)
135
+ }, [options])
136
+
137
+ const onClickOutside = useCallback((event: MouseEvent) => {
138
+ if (fakeSelectRef.current && !fakeSelectRef.current.contains(event.target as Node)) setOpen(false)
139
+ }, [])
140
+
141
+ const [htmlOptions, fakeOptions] = useMemo(
142
+ () => (options ?? []).reduce<[React.ReactElement[], React.ReactElement[]]>(([opts, fake], o) => {
143
+ const id = getOptionAsValue(o, renderValue)
144
+ const label = getOptionAsLabel(o, renderLabel)
145
+ return [
146
+ [...opts, <option key={id} value={id} selected={value === id}>{label.text}</option>],
147
+ [...fake, <FakeOption key={id} value={id} label={label.option} onChange={onChangeOption} />],
148
+ ]
149
+ }, [[], []]),
150
+ [options, value],
151
+ )
152
+
153
+ function getCurrentValue() {
154
+ return value === undefined ? '' : getOptionAsValue(value, renderValue)
155
+ }
156
+
157
+ function getCurrentLabel() {
158
+ return value === undefined ? emptyOption : getOptionAsLabel(value, renderLabel)
159
+ }
160
+
161
+ useEffect(() => {
162
+ const detach = () => document.removeEventListener('mousedown', onClickOutside)
163
+ if (open) document.addEventListener('mousedown', onClickOutside)
164
+ else detach()
165
+ return detach
166
+ }, [open])
167
+
168
+ useEffect(() => {
169
+ async function measure() {
170
+ // semaphore for controlling concurrence
171
+ if (isMeasuring.current) return
172
+ isMeasuring.current = true
173
+ const list = fakeSelectRef.current?.querySelector('.options')
174
+ if (!list) return
175
+ list.setAttribute('inert', '')
176
+ list.setAttribute('style', 'height: auto')
177
+ await delay(0)
178
+ listHeight.current = list.clientHeight
179
+ await delay(0)
180
+ list.setAttribute('style', `height: ${open ? listHeight.current : 0}`)
181
+ list.removeAttribute('inert')
182
+ isMeasuring.current = false
183
+ }
184
+ measure()
185
+ }, [options, fakeSelectRef.current])
186
+
187
+ // replicates the original select effect of the browser to select the first option. This is necessary because we use the default
188
+ // select element for accessibility, the behavior must not differ.
189
+ useEffect(() => {
190
+ if (!value && !emptyOption && options?.length) {
191
+ onChange(options[0])
192
+ }
193
+ }, [options])
194
+
195
+ return (
196
+ <SelectBox style={style} className={className} $maxItems={maxItems} $inputHeight={height}>
197
+ { /* Screen readers can use the select component from the browser instead of the highly styled component we show. */ }
198
+ <select
199
+ {...props}
200
+ value={getCurrentValue()}
201
+ onChange={onChangeOption}
202
+ onFocus={(ev) => {
203
+ setFocused(true)
204
+ onFocus?.(ev)
205
+ }}
206
+ onBlur={(ev) => {
207
+ setFocused(false)
208
+ onBlur?.(ev)
209
+ }}
210
+ disabled={isDisabled}
211
+ aria-busy={isLoading}
212
+ >
213
+ {emptyOption === undefined ? null : <option value="" selected={!value}>{emptyOption.text}</option>}
214
+ {htmlOptions}
215
+ </select>
216
+ <div
217
+ ref={fakeSelectRef}
218
+ className={listToClass(['fake-select', open && 'open', focused && 'focused', isDisabled && 'disabled'])}
219
+ aria-hidden
220
+ >
221
+ <div className="current-value" onClick={isDisabled ? undefined : () => setOpen(!open)}>
222
+ {getCurrentLabel()?.selected || getCurrentLabel()?.option || <div></div>}
223
+ {isLoading ? <LoadingCircular size="sm" /> : <IconBox className="arrow"><ChevronDown /></IconBox>}
224
+ </div>
225
+ <ul className="options" style={{ height: open ? listHeight.current : 0 }}>
226
+ {emptyOption === undefined ? null : <FakeOption value="" label={emptyOption.option} onChange={onChangeOption} />}
227
+ {fakeOptions}
228
+ </ul>
229
+ </div>
230
+ </SelectBox>
231
+ )
232
+ }