@keycloakify/keycloak-ui-shared 26.0.6001

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 (66) hide show
  1. package/LICENSE +2 -0
  2. package/README.md +6 -0
  3. package/keycloak-theme/shared/keycloak-ui-shared/alerts/AlertPanel.tsx +43 -0
  4. package/keycloak-theme/shared/keycloak-ui-shared/alerts/Alerts.tsx +82 -0
  5. package/keycloak-theme/shared/keycloak-ui-shared/buttons/FormSubmitButton.tsx +47 -0
  6. package/keycloak-theme/shared/keycloak-ui-shared/context/ErrorPage.tsx +60 -0
  7. package/keycloak-theme/shared/keycloak-ui-shared/context/HelpContext.tsx +30 -0
  8. package/keycloak-theme/shared/keycloak-ui-shared/context/KeycloakContext.tsx +97 -0
  9. package/keycloak-theme/shared/keycloak-ui-shared/context/environment.ts +50 -0
  10. package/keycloak-theme/shared/keycloak-ui-shared/continue-cancel/ContinueCancelModal.tsx +75 -0
  11. package/keycloak-theme/shared/keycloak-ui-shared/controls/FormErrorText.tsx +23 -0
  12. package/keycloak-theme/shared/keycloak-ui-shared/controls/FormLabel.tsx +40 -0
  13. package/keycloak-theme/shared/keycloak-ui-shared/controls/HelpItem.tsx +43 -0
  14. package/keycloak-theme/shared/keycloak-ui-shared/controls/KeycloakSpinner.tsx +12 -0
  15. package/keycloak-theme/shared/keycloak-ui-shared/controls/NumberControl.tsx +93 -0
  16. package/keycloak-theme/shared/keycloak-ui-shared/controls/OrganizationTable.tsx +122 -0
  17. package/keycloak-theme/shared/keycloak-ui-shared/controls/PasswordControl.tsx +71 -0
  18. package/keycloak-theme/shared/keycloak-ui-shared/controls/PasswordInput.tsx +50 -0
  19. package/keycloak-theme/shared/keycloak-ui-shared/controls/SwitchControl.tsx +67 -0
  20. package/keycloak-theme/shared/keycloak-ui-shared/controls/TextAreaControl.tsx +60 -0
  21. package/keycloak-theme/shared/keycloak-ui-shared/controls/TextControl.tsx +75 -0
  22. package/keycloak-theme/shared/keycloak-ui-shared/controls/keycloak-text-area/KeycloakTextArea.tsx +23 -0
  23. package/keycloak-theme/shared/keycloak-ui-shared/controls/select-control/SelectControl.tsx +75 -0
  24. package/keycloak-theme/shared/keycloak-ui-shared/controls/select-control/SingleSelectControl.tsx +109 -0
  25. package/keycloak-theme/shared/keycloak-ui-shared/controls/select-control/TypeaheadSelectControl.tsx +285 -0
  26. package/keycloak-theme/shared/keycloak-ui-shared/controls/table/KeycloakDataTable.tsx +597 -0
  27. package/keycloak-theme/shared/keycloak-ui-shared/controls/table/ListEmptyState.tsx +86 -0
  28. package/keycloak-theme/shared/keycloak-ui-shared/controls/table/PaginatingTableToolbar.tsx +106 -0
  29. package/keycloak-theme/shared/keycloak-ui-shared/controls/table/TableToolbar.tsx +92 -0
  30. package/keycloak-theme/shared/keycloak-ui-shared/icons/IconMapper.tsx +63 -0
  31. package/keycloak-theme/shared/keycloak-ui-shared/index.ts +1 -0
  32. package/keycloak-theme/shared/keycloak-ui-shared/main.ts +96 -0
  33. package/keycloak-theme/shared/keycloak-ui-shared/masthead/DefaultAvatar.tsx +109 -0
  34. package/keycloak-theme/shared/keycloak-ui-shared/masthead/KeycloakDropdown.tsx +48 -0
  35. package/keycloak-theme/shared/keycloak-ui-shared/masthead/Masthead.tsx +161 -0
  36. package/keycloak-theme/shared/keycloak-ui-shared/scroll-form/FormPanel.tsx +29 -0
  37. package/keycloak-theme/shared/keycloak-ui-shared/scroll-form/FormTitle.tsx +28 -0
  38. package/keycloak-theme/shared/keycloak-ui-shared/scroll-form/ScrollForm.tsx +98 -0
  39. package/keycloak-theme/shared/keycloak-ui-shared/scroll-form/ScrollPanel.tsx +21 -0
  40. package/keycloak-theme/shared/keycloak-ui-shared/scroll-form/form-title.module.css +4 -0
  41. package/keycloak-theme/shared/keycloak-ui-shared/scroll-form/scroll-form.module.css +8 -0
  42. package/keycloak-theme/shared/keycloak-ui-shared/select/KeycloakSelect.tsx +49 -0
  43. package/keycloak-theme/shared/keycloak-ui-shared/select/SingleSelect.tsx +89 -0
  44. package/keycloak-theme/shared/keycloak-ui-shared/select/TypeaheadSelect.tsx +198 -0
  45. package/keycloak-theme/shared/keycloak-ui-shared/user-profile/LocaleSelector.tsx +51 -0
  46. package/keycloak-theme/shared/keycloak-ui-shared/user-profile/MultiInputComponent.tsx +146 -0
  47. package/keycloak-theme/shared/keycloak-ui-shared/user-profile/OptionsComponent.tsx +63 -0
  48. package/keycloak-theme/shared/keycloak-ui-shared/user-profile/SelectComponent.tsx +109 -0
  49. package/keycloak-theme/shared/keycloak-ui-shared/user-profile/TextAreaComponent.tsx +23 -0
  50. package/keycloak-theme/shared/keycloak-ui-shared/user-profile/TextComponent.tsx +32 -0
  51. package/keycloak-theme/shared/keycloak-ui-shared/user-profile/UserProfileFields.tsx +243 -0
  52. package/keycloak-theme/shared/keycloak-ui-shared/user-profile/UserProfileGroup.tsx +71 -0
  53. package/keycloak-theme/shared/keycloak-ui-shared/user-profile/utils.ts +170 -0
  54. package/keycloak-theme/shared/keycloak-ui-shared/utils/ErrorBoundary.tsx +77 -0
  55. package/keycloak-theme/shared/keycloak-ui-shared/utils/createNamedContext.ts +11 -0
  56. package/keycloak-theme/shared/keycloak-ui-shared/utils/darkMode.ts +19 -0
  57. package/keycloak-theme/shared/keycloak-ui-shared/utils/errors.ts +55 -0
  58. package/keycloak-theme/shared/keycloak-ui-shared/utils/generateId.ts +1 -0
  59. package/keycloak-theme/shared/keycloak-ui-shared/utils/getRuleValue.ts +17 -0
  60. package/keycloak-theme/shared/keycloak-ui-shared/utils/isDefined.ts +3 -0
  61. package/keycloak-theme/shared/keycloak-ui-shared/utils/useFetch.ts +44 -0
  62. package/keycloak-theme/shared/keycloak-ui-shared/utils/useRequiredContext.ts +24 -0
  63. package/keycloak-theme/shared/keycloak-ui-shared/utils/useSetTimeout.ts +40 -0
  64. package/keycloak-theme/shared/keycloak-ui-shared/utils/useStorageItem.ts +51 -0
  65. package/keycloak-theme/shared/keycloak-ui-shared/utils/useStoredState.ts +38 -0
  66. package/package.json +31 -0
@@ -0,0 +1,29 @@
1
+ import { Card, CardBody, CardHeader, CardTitle } from "@patternfly/react-core";
2
+ import { PropsWithChildren, useId } from "react";
3
+ import { FormTitle } from "./FormTitle";
4
+
5
+ type FormPanelProps = {
6
+ title: string;
7
+ scrollId?: string;
8
+ className?: string;
9
+ };
10
+
11
+ export const FormPanel = ({
12
+ title,
13
+ children,
14
+ scrollId,
15
+ className,
16
+ }: PropsWithChildren<FormPanelProps>) => {
17
+ const id = useId();
18
+
19
+ return (
20
+ <Card id={id} className={className} isFlat>
21
+ <CardHeader className="kc-form-panel__header">
22
+ <CardTitle tabIndex={0}>
23
+ <FormTitle id={scrollId} title={title} />
24
+ </CardTitle>
25
+ </CardHeader>
26
+ <CardBody className="kc-form-panel__body">{children}</CardBody>
27
+ </Card>
28
+ );
29
+ };
@@ -0,0 +1,28 @@
1
+ import { Title, TitleProps } from "@patternfly/react-core";
2
+
3
+ import style from "./form-title.module.css";
4
+
5
+ type FormTitleProps = Omit<TitleProps, "headingLevel"> & {
6
+ id?: string;
7
+ title: string;
8
+ headingLevel?: "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
9
+ };
10
+
11
+ export const FormTitle = ({
12
+ id,
13
+ title,
14
+ headingLevel = "h1",
15
+ size = "xl",
16
+ ...rest
17
+ }: FormTitleProps) => (
18
+ <Title
19
+ headingLevel={headingLevel}
20
+ size={size}
21
+ className={style.title}
22
+ id={id}
23
+ tabIndex={0} // so that jumpLink sends focus to the section for a11y
24
+ {...rest}
25
+ >
26
+ {title}
27
+ </Title>
28
+ );
@@ -0,0 +1,98 @@
1
+ import {
2
+ Grid,
3
+ GridItem,
4
+ GridProps,
5
+ JumpLinks,
6
+ JumpLinksItem,
7
+ PageSection,
8
+ } from "@patternfly/react-core";
9
+ import { Fragment, ReactNode, useMemo } from "react";
10
+ import { FormPanel } from "./FormPanel";
11
+ import { ScrollPanel } from "./ScrollPanel";
12
+
13
+ import style from "./scroll-form.module.css";
14
+
15
+ export const mainPageContentId = "kc-main-content-page-container";
16
+
17
+ type ScrollSection = {
18
+ title: string;
19
+ panel: ReactNode;
20
+ isHidden?: boolean;
21
+ };
22
+
23
+ type ScrollFormProps = GridProps & {
24
+ label: string;
25
+ sections: ScrollSection[];
26
+ borders?: boolean;
27
+ };
28
+
29
+ const spacesToHyphens = (string: string): string => {
30
+ return string.replace(/\s+/g, "-");
31
+ };
32
+
33
+ export const ScrollForm = ({
34
+ label,
35
+ sections,
36
+ borders = false,
37
+ ...rest
38
+ }: ScrollFormProps) => {
39
+ const shownSections = useMemo(
40
+ () => sections.filter(({ isHidden }) => !isHidden),
41
+ [sections],
42
+ );
43
+
44
+ return (
45
+ <Grid hasGutter {...rest}>
46
+ <GridItem md={8} sm={12}>
47
+ {shownSections.map(({ title, panel }) => {
48
+ const scrollId = spacesToHyphens(title.toLowerCase());
49
+
50
+ return (
51
+ <Fragment key={title}>
52
+ {borders ? (
53
+ <FormPanel
54
+ scrollId={scrollId}
55
+ title={title}
56
+ className={style.panel}
57
+ >
58
+ {panel}
59
+ </FormPanel>
60
+ ) : (
61
+ <ScrollPanel scrollId={scrollId} title={title}>
62
+ {panel}
63
+ </ScrollPanel>
64
+ )}
65
+ </Fragment>
66
+ );
67
+ })}
68
+ </GridItem>
69
+ <GridItem md={4} sm={12} order={{ default: "-1", md: "1" }}>
70
+ <PageSection className={style.sticky}>
71
+ <JumpLinks
72
+ isVertical
73
+ // scrollableSelector has to point to the id of the element whose scrollTop changes
74
+ // to scroll the entire main section, it has to be the pf-v5-c-page__main
75
+ scrollableSelector={`#${mainPageContentId}`}
76
+ label={label}
77
+ offset={100}
78
+ >
79
+ {shownSections.map(({ title }) => {
80
+ const scrollId = spacesToHyphens(title.toLowerCase());
81
+
82
+ return (
83
+ // note that JumpLinks currently does not work with spaces in the href
84
+ <JumpLinksItem
85
+ key={title}
86
+ href={`#${scrollId}`}
87
+ data-testid={`jump-link-${scrollId}`}
88
+ >
89
+ {title}
90
+ </JumpLinksItem>
91
+ );
92
+ })}
93
+ </JumpLinks>
94
+ </PageSection>
95
+ </GridItem>
96
+ </Grid>
97
+ );
98
+ };
@@ -0,0 +1,21 @@
1
+ /* eslint-disable react/jsx-no-useless-fragment */
2
+ // See: https://github.com/i18next/react-i18next/issues/1543
3
+ import { HTMLProps } from "react";
4
+ import { FormTitle } from "./FormTitle";
5
+
6
+ type ScrollPanelProps = HTMLProps<HTMLFormElement> & {
7
+ title: string;
8
+ scrollId: string;
9
+ };
10
+
11
+ export const ScrollPanel = (props: ScrollPanelProps) => {
12
+ const { title, children, scrollId, ...rest } = props;
13
+ return (
14
+ <section {...rest} style={{ marginTop: "var(--pf-v5-global--spacer--lg)" }}>
15
+ <>
16
+ <FormTitle id={scrollId} title={title} />
17
+ {children}
18
+ </>
19
+ </section>
20
+ );
21
+ };
@@ -0,0 +1,4 @@
1
+
2
+ .title {
3
+ margin-bottom: var(--pf-v5-global--spacer--lg);
4
+ }
@@ -0,0 +1,8 @@
1
+ .panel {
2
+ margin-top: var(--pf-v5-global--spacer--lg);
3
+ }
4
+
5
+ .sticky {
6
+ position: sticky;
7
+ top: 0;
8
+ }
@@ -0,0 +1,49 @@
1
+ import { ChipGroupProps, SelectProps } from "@patternfly/react-core";
2
+ import { SingleSelect } from "./SingleSelect";
3
+ import { TypeaheadSelect } from "./TypeaheadSelect";
4
+
5
+ export type Variant = `${SelectVariant}`;
6
+
7
+ export enum SelectVariant {
8
+ single = "single",
9
+ typeahead = "typeahead",
10
+ typeaheadMulti = "typeaheadMulti",
11
+ }
12
+
13
+ export const propertyToString = (prop: string | number | undefined) =>
14
+ typeof prop === "number" ? prop + "px" : prop;
15
+
16
+ export type KeycloakSelectProps<> = Omit<
17
+ SelectProps,
18
+ "name" | "toggle" | "selected" | "onClick" | "onSelect"
19
+ > & {
20
+ toggleId?: string;
21
+ onFilter?: (value: string) => JSX.Element[];
22
+ onClear?: () => void;
23
+ variant?: Variant;
24
+ isDisabled?: boolean;
25
+ menuAppendTo?: string;
26
+ maxHeight?: string | number;
27
+ width?: string | number;
28
+ toggleIcon?: React.ReactElement;
29
+ direction?: "up" | "down";
30
+ placeholderText?: string;
31
+ onSelect?: (value: string | number | object) => void;
32
+ onToggle: (val: boolean) => void;
33
+ selections?: string | string[] | number | number[];
34
+ validated?: "success" | "warning" | "error" | "default";
35
+ typeAheadAriaLabel?: string;
36
+ chipGroupProps?: Omit<ChipGroupProps, "children" | "ref">;
37
+ chipGroupComponent?: React.ReactNode;
38
+ footer?: React.ReactNode;
39
+ };
40
+ export const KeycloakSelect = ({
41
+ variant = SelectVariant.single,
42
+ ...rest
43
+ }: KeycloakSelectProps) => {
44
+ if (variant === SelectVariant.single) {
45
+ return <SingleSelect {...rest} />;
46
+ } else {
47
+ return <TypeaheadSelect {...rest} variant={variant} />;
48
+ }
49
+ };
@@ -0,0 +1,89 @@
1
+ import {
2
+ MenuToggle,
3
+ Select,
4
+ SelectList,
5
+ SelectOptionProps,
6
+ } from "@patternfly/react-core";
7
+ import { Children, useRef, useState } from "react";
8
+ import { KeycloakSelectProps, propertyToString } from "./KeycloakSelect";
9
+
10
+ type SingleSelectProps = Omit<KeycloakSelectProps, "variant">;
11
+
12
+ export const SingleSelect = ({
13
+ toggleId,
14
+ onToggle,
15
+ onSelect,
16
+ selections,
17
+ isOpen,
18
+ menuAppendTo,
19
+ direction,
20
+ width,
21
+ maxHeight,
22
+ toggleIcon,
23
+ className,
24
+ isDisabled,
25
+ children,
26
+ ...props
27
+ }: SingleSelectProps) => {
28
+ const [open, setOpen] = useState(false);
29
+ const ref = useRef<HTMLElement>();
30
+ const toggle = () => {
31
+ setOpen(!open);
32
+ onToggle(!open);
33
+ };
34
+
35
+ const append = () => {
36
+ if (menuAppendTo === "parent") {
37
+ return ref.current?.parentElement || "inline";
38
+ }
39
+ return "inline";
40
+ };
41
+
42
+ const childArray = Children.toArray(
43
+ children,
44
+ ) as React.ReactElement<SelectOptionProps>[];
45
+
46
+ return (
47
+ <Select
48
+ ref={ref}
49
+ maxMenuHeight={propertyToString(maxHeight)}
50
+ isScrollable
51
+ popperProps={{
52
+ appendTo: append(),
53
+ direction,
54
+ width: propertyToString(width),
55
+ }}
56
+ {...props}
57
+ onClick={toggle}
58
+ onOpenChange={(isOpen) => {
59
+ if (isOpen !== open) toggle();
60
+ }}
61
+ selected={selections}
62
+ onSelect={(_, value) => {
63
+ onSelect?.(value || "");
64
+ toggle();
65
+ }}
66
+ toggle={(ref) => (
67
+ <MenuToggle
68
+ id={toggleId}
69
+ ref={ref}
70
+ className={className}
71
+ onClick={toggle}
72
+ isExpanded={isOpen}
73
+ aria-label={props["aria-label"]}
74
+ icon={toggleIcon}
75
+ isDisabled={isDisabled}
76
+ isFullWidth
77
+ >
78
+ {childArray.find((c) => c.props.value === selections)?.props
79
+ .children ||
80
+ selections ||
81
+ props["aria-label"]}
82
+ </MenuToggle>
83
+ )}
84
+ isOpen={isOpen}
85
+ >
86
+ <SelectList>{children}</SelectList>
87
+ </Select>
88
+ );
89
+ };
@@ -0,0 +1,198 @@
1
+ import {
2
+ Button,
3
+ Chip,
4
+ ChipGroup,
5
+ MenuFooter,
6
+ MenuToggle,
7
+ MenuToggleStatus,
8
+ Select,
9
+ SelectList,
10
+ SelectOptionProps,
11
+ TextInputGroup,
12
+ TextInputGroupMain,
13
+ TextInputGroupUtilities,
14
+ } from "@patternfly/react-core";
15
+ import { TimesIcon } from "@patternfly/react-icons";
16
+ import { Children, useRef, useState } from "react";
17
+ import {
18
+ KeycloakSelectProps,
19
+ SelectVariant,
20
+ propertyToString,
21
+ } from "./KeycloakSelect";
22
+
23
+ export const TypeaheadSelect = ({
24
+ toggleId,
25
+ onSelect,
26
+ onToggle,
27
+ onFilter,
28
+ variant,
29
+ validated,
30
+ placeholderText,
31
+ maxHeight,
32
+ width,
33
+ toggleIcon,
34
+ direction,
35
+ selections,
36
+ typeAheadAriaLabel,
37
+ chipGroupComponent,
38
+ chipGroupProps,
39
+ footer,
40
+ isDisabled,
41
+ children,
42
+ ...rest
43
+ }: KeycloakSelectProps) => {
44
+ const [filterValue, setFilterValue] = useState("");
45
+ const [focusedItemIndex, setFocusedItemIndex] = useState<number>(0);
46
+ const textInputRef = useRef<HTMLInputElement>();
47
+
48
+ const childArray = Children.toArray(
49
+ children,
50
+ ) as React.ReactElement<SelectOptionProps>[];
51
+
52
+ const toggle = () => {
53
+ onToggle?.(!rest.isOpen);
54
+ };
55
+
56
+ const onInputKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
57
+ const focusedItem = childArray[focusedItemIndex];
58
+ onToggle?.(true);
59
+
60
+ switch (event.key) {
61
+ case "Enter": {
62
+ event.preventDefault();
63
+
64
+ if (variant !== SelectVariant.typeaheadMulti) {
65
+ setFilterValue(focusedItem.props.value);
66
+ } else {
67
+ setFilterValue("");
68
+ }
69
+ onSelect?.(focusedItem.props.value);
70
+ onToggle?.(false);
71
+ setFocusedItemIndex(0);
72
+
73
+ break;
74
+ }
75
+ case "Escape": {
76
+ onToggle?.(false);
77
+ break;
78
+ }
79
+ case "Backspace": {
80
+ if (variant === SelectVariant.typeahead) {
81
+ onSelect?.("");
82
+ }
83
+ break;
84
+ }
85
+ case "ArrowUp":
86
+ case "ArrowDown": {
87
+ event.preventDefault();
88
+
89
+ let indexToFocus = 0;
90
+
91
+ if (event.key === "ArrowUp") {
92
+ if (focusedItemIndex === 0) {
93
+ indexToFocus = childArray.length - 1;
94
+ } else {
95
+ indexToFocus = focusedItemIndex - 1;
96
+ }
97
+ }
98
+
99
+ if (event.key === "ArrowDown") {
100
+ if (focusedItemIndex === childArray.length - 1) {
101
+ indexToFocus = 0;
102
+ } else {
103
+ indexToFocus = focusedItemIndex + 1;
104
+ }
105
+ }
106
+
107
+ setFocusedItemIndex(indexToFocus);
108
+ break;
109
+ }
110
+ }
111
+ };
112
+
113
+ return (
114
+ <Select
115
+ {...rest}
116
+ onClick={toggle}
117
+ onOpenChange={(isOpen) => onToggle?.(isOpen)}
118
+ onSelect={(_, value) => onSelect?.(value || "")}
119
+ maxMenuHeight={propertyToString(maxHeight)}
120
+ popperProps={{ direction, width: propertyToString(width) }}
121
+ toggle={(ref) => (
122
+ <MenuToggle
123
+ ref={ref}
124
+ id={toggleId}
125
+ variant="typeahead"
126
+ onClick={() => onToggle?.(true)}
127
+ icon={toggleIcon}
128
+ isDisabled={isDisabled}
129
+ isExpanded={rest.isOpen}
130
+ isFullWidth
131
+ status={validated === "error" ? MenuToggleStatus.danger : undefined}
132
+ >
133
+ <TextInputGroup isPlain>
134
+ <TextInputGroupMain
135
+ placeholder={placeholderText}
136
+ value={
137
+ variant === SelectVariant.typeahead && selections
138
+ ? (selections as string)
139
+ : filterValue
140
+ }
141
+ onClick={toggle}
142
+ onChange={(_, value) => {
143
+ setFilterValue(value);
144
+ onFilter?.(value);
145
+ }}
146
+ onKeyDown={(event) => onInputKeyDown(event)}
147
+ autoComplete="off"
148
+ innerRef={textInputRef}
149
+ role="combobox"
150
+ isExpanded={rest.isOpen}
151
+ aria-controls="select-typeahead-listbox"
152
+ aria-label={typeAheadAriaLabel}
153
+ >
154
+ {variant === SelectVariant.typeaheadMulti &&
155
+ Array.isArray(selections) &&
156
+ (chipGroupComponent ? (
157
+ chipGroupComponent
158
+ ) : (
159
+ <ChipGroup {...chipGroupProps}>
160
+ {selections.map((selection, index: number) => (
161
+ <Chip
162
+ key={index}
163
+ onClick={(ev) => {
164
+ ev.stopPropagation();
165
+ onSelect?.(selection);
166
+ }}
167
+ >
168
+ {selection}
169
+ </Chip>
170
+ ))}
171
+ </ChipGroup>
172
+ ))}
173
+ </TextInputGroupMain>
174
+ <TextInputGroupUtilities>
175
+ {!!filterValue && (
176
+ <Button
177
+ variant="plain"
178
+ onClick={() => {
179
+ onSelect?.("");
180
+ setFilterValue("");
181
+ onFilter?.("");
182
+ textInputRef?.current?.focus();
183
+ }}
184
+ aria-label="Clear input value"
185
+ >
186
+ <TimesIcon aria-hidden />
187
+ </Button>
188
+ )}
189
+ </TextInputGroupUtilities>
190
+ </TextInputGroup>
191
+ </MenuToggle>
192
+ )}
193
+ >
194
+ <SelectList>{children}</SelectList>
195
+ {footer && <MenuFooter>{footer}</MenuFooter>}
196
+ </Select>
197
+ );
198
+ };
@@ -0,0 +1,51 @@
1
+ import { useMemo } from "react";
2
+ import { FormProvider } from "react-hook-form";
3
+ import { SelectControl } from "../controls/select-control/SelectControl";
4
+ import { UserProfileFieldProps } from "./UserProfileFields";
5
+
6
+ const localeToDisplayName = (locale: string) => {
7
+ try {
8
+ return new Intl.DisplayNames([locale], { type: "language" }).of(locale);
9
+ } catch {
10
+ return locale;
11
+ }
12
+ };
13
+
14
+ type LocaleSelectorProps = Omit<UserProfileFieldProps, "inputType"> & {
15
+ supportedLocales: string[];
16
+ currentLocale: string;
17
+ };
18
+
19
+ export const LocaleSelector = ({
20
+ t,
21
+ form,
22
+ supportedLocales,
23
+ currentLocale,
24
+ }: LocaleSelectorProps) => {
25
+ const locales = useMemo(
26
+ () =>
27
+ supportedLocales
28
+ .map((locale) => ({
29
+ key: locale,
30
+ value: t(`locale_${locale}`, localeToDisplayName(locale) ?? locale),
31
+ }))
32
+ .sort((a, b) => a.value.localeCompare(b.value, currentLocale)),
33
+ [supportedLocales, currentLocale, t],
34
+ );
35
+
36
+ if (!locales.length) {
37
+ return null;
38
+ }
39
+ return (
40
+ <FormProvider {...form}>
41
+ <SelectControl
42
+ data-testid="locale-select"
43
+ name="attributes.locale"
44
+ label={t("selectALocale")}
45
+ controller={{ defaultValue: "" }}
46
+ options={locales}
47
+ variant={locales.length >= 10 ? "typeahead" : "single"}
48
+ />
49
+ </FormProvider>
50
+ );
51
+ };