nexus-shared 1.1.20 → 2.0.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.
- package/LICENSE +21 -0
- package/README.md +903 -0
- package/dist/Client.Index.d.ts +44 -0
- package/dist/Client.Index.js +49 -0
- package/dist/Components/Documents/Button.d.ts +29 -0
- package/dist/Components/Documents/Button.js +11 -0
- package/dist/Components/Documents/Menu.d.ts +102 -0
- package/dist/Components/Documents/Menu.js +238 -0
- package/dist/Components/Documents/SplitButton.d.ts +64 -0
- package/dist/Components/Documents/SplitButton.js +127 -0
- package/dist/Components/Documents/TabButtons.d.ts +62 -0
- package/dist/Components/Documents/TabButtons.js +19 -0
- package/dist/Components/Forms/ApiForm.d.ts +21 -0
- package/dist/Components/Forms/ApiForm.js +99 -0
- package/dist/Components/Forms/Crud.d.ts +17 -0
- package/dist/Components/Forms/Crud.js +556 -0
- package/dist/Components/Forms/CrudParts.d.ts +15 -0
- package/dist/Components/Forms/CrudParts.js +36 -0
- package/dist/Components/Forms/Form.d.ts +52 -0
- package/dist/Components/Forms/Form.js +319 -0
- package/dist/Components/Forms/SubmitForm.d.ts +65 -0
- package/dist/Components/Forms/SubmitForm.js +198 -0
- package/dist/Components/Inputs/Calendar.d.ts +69 -0
- package/dist/Components/Inputs/Calendar.js +258 -0
- package/dist/Components/Inputs/CheckBox.d.ts +53 -0
- package/dist/Components/Inputs/CheckBox.js +87 -0
- package/dist/Components/Inputs/CheckBoxGroup.d.ts +20 -0
- package/dist/Components/Inputs/CheckBoxGroup.js +80 -0
- package/dist/Components/Inputs/ChoiceField.d.ts +122 -0
- package/dist/Components/Inputs/ChoiceField.js +169 -0
- package/dist/Components/Inputs/DatePicker.d.ts +54 -0
- package/dist/Components/Inputs/DatePicker.js +246 -0
- package/dist/Components/Inputs/DateRangePicker.d.ts +62 -0
- package/dist/Components/Inputs/DateRangePicker.js +283 -0
- package/dist/Components/Inputs/DateTimePicker.d.ts +75 -0
- package/dist/Components/Inputs/DateTimePicker.js +399 -0
- package/dist/Components/Inputs/Dropdown.d.ts +117 -0
- package/dist/Components/Inputs/Dropdown.js +839 -0
- package/dist/Components/Inputs/DropdownParts.d.ts +84 -0
- package/dist/Components/Inputs/DropdownParts.js +102 -0
- package/dist/Components/Inputs/EditorShell.d.ts +45 -0
- package/dist/Components/Inputs/EditorShell.js +214 -0
- package/dist/Components/Inputs/Field.d.ts +53 -0
- package/dist/Components/Inputs/Field.js +16 -0
- package/dist/Components/Inputs/GroupForm.d.ts +20 -0
- package/dist/Components/Inputs/GroupForm.js +170 -0
- package/dist/Components/Inputs/InputField.d.ts +59 -0
- package/dist/Components/Inputs/InputField.js +169 -0
- package/dist/Components/Inputs/InputRenderer.d.ts +35 -0
- package/dist/Components/Inputs/InputRenderer.js +99 -0
- package/dist/Components/Inputs/MarkdownEditor.d.ts +67 -0
- package/dist/Components/Inputs/MarkdownEditor.js +289 -0
- package/dist/Components/Inputs/NumberBox.d.ts +53 -0
- package/dist/Components/Inputs/NumberBox.js +228 -0
- package/dist/Components/Inputs/RadioGroup.d.ts +20 -0
- package/dist/Components/Inputs/RadioGroup.js +43 -0
- package/dist/Components/Inputs/ReadOnlyNotice.d.ts +36 -0
- package/dist/Components/Inputs/ReadOnlyNotice.js +116 -0
- package/dist/Components/Inputs/RichTextEditor.d.ts +103 -0
- package/dist/Components/Inputs/RichTextEditor.js +1170 -0
- package/dist/Components/Inputs/RichTextParts.d.ts +63 -0
- package/dist/Components/Inputs/RichTextParts.js +157 -0
- package/dist/Components/Inputs/RowsInput.d.ts +104 -0
- package/dist/Components/Inputs/RowsInput.js +259 -0
- package/dist/Components/Inputs/Slider.d.ts +87 -0
- package/dist/Components/Inputs/Slider.js +216 -0
- package/dist/Components/Inputs/TabularForm.d.ts +23 -0
- package/dist/Components/Inputs/TabularForm.js +283 -0
- package/dist/Components/Inputs/TextArea.d.ts +43 -0
- package/dist/Components/Inputs/TextArea.js +31 -0
- package/dist/Components/Inputs/TextBox.d.ts +52 -0
- package/dist/Components/Inputs/TextBox.js +58 -0
- package/dist/Components/Inputs/TextField.d.ts +31 -0
- package/dist/Components/Inputs/TextField.js +35 -0
- package/dist/Components/Inputs/TimePanel.d.ts +34 -0
- package/dist/Components/Inputs/TimePanel.js +288 -0
- package/dist/Components/Inputs/TimePicker.d.ts +54 -0
- package/dist/Components/Inputs/TimePicker.js +239 -0
- package/dist/Components/Inputs/TreeView.d.ts +160 -0
- package/dist/Components/Inputs/TreeView.js +947 -0
- package/dist/Components/Layouts/MessageBox.d.ts +71 -0
- package/dist/Components/Layouts/MessageBox.js +104 -0
- package/dist/Components/Layouts/Popup.d.ts +92 -0
- package/dist/Components/Layouts/Popup.js +376 -0
- package/dist/Components/Layouts/PopupContext.d.ts +2 -0
- package/dist/Components/Layouts/PopupContext.js +4 -0
- package/dist/Components/Layouts/PopupDock.d.ts +22 -0
- package/dist/Components/Layouts/PopupDock.js +121 -0
- package/dist/Components/Layouts/PopupHost.d.ts +56 -0
- package/dist/Components/Layouts/PopupHost.js +163 -0
- package/dist/Components/Layouts/PopupRules.d.ts +30 -0
- package/dist/Components/Layouts/PopupRules.js +63 -0
- package/dist/Components/Layouts/ThemePicker.d.ts +13 -0
- package/dist/Components/Layouts/ThemePicker.js +22 -0
- package/dist/Components/Layouts/ThemeScript.d.ts +14 -0
- package/dist/Components/Layouts/ThemeScript.js +13 -0
- package/dist/Components/Layouts/ThemeSwitcher.d.ts +13 -0
- package/dist/Components/Layouts/ThemeSwitcher.js +29 -0
- package/dist/Components/Layouts/Toaster.d.ts +76 -0
- package/dist/Components/Layouts/Toaster.js +172 -0
- package/dist/Components/Viewers/DataTable.d.ts +25 -0
- package/dist/Components/Viewers/DataTable.js +542 -0
- package/dist/Components/Viewers/DataTableParts.d.ts +134 -0
- package/dist/Components/Viewers/DataTableParts.js +145 -0
- package/dist/Components/Viewers/MarkdownView.d.ts +26 -0
- package/dist/Components/Viewers/MarkdownView.js +79 -0
- package/dist/Components/Viewers/RichTextView.d.ts +18 -0
- package/dist/Components/Viewers/RichTextView.js +17 -0
- package/dist/Helpers/AnimationHelpers.d.ts +6 -0
- package/dist/Helpers/AnimationHelpers.js +35 -0
- package/dist/Helpers/ApiClient.d.ts +74 -0
- package/dist/Helpers/ApiClient.js +197 -0
- package/dist/Helpers/ApiFormHelpers.d.ts +26 -0
- package/dist/Helpers/ApiFormHelpers.js +56 -0
- package/dist/Helpers/ApiModules.d.ts +135 -0
- package/dist/Helpers/ApiModules.js +194 -0
- package/dist/Helpers/ApiResponses.d.ts +41 -0
- package/dist/Helpers/ApiResponses.js +159 -0
- package/dist/Helpers/ApiRoutes.d.ts +150 -0
- package/dist/Helpers/ApiRoutes.js +191 -0
- package/dist/Helpers/ApiToasts.d.ts +21 -0
- package/dist/Helpers/ApiToasts.js +22 -0
- package/dist/Helpers/ClassHelpers.d.ts +3 -0
- package/dist/Helpers/ClassHelpers.js +4 -0
- package/dist/Helpers/CrudBackend.d.ts +56 -0
- package/dist/Helpers/CrudBackend.js +119 -0
- package/dist/Helpers/CrudHelpers.d.ts +76 -0
- package/dist/Helpers/CrudHelpers.js +202 -0
- package/dist/Helpers/DateHelpers.d.ts +88 -0
- package/dist/Helpers/DateHelpers.js +363 -0
- package/dist/Helpers/DatePreferences.d.ts +14 -0
- package/dist/Helpers/DatePreferences.js +94 -0
- package/dist/Helpers/DateTimeHelpers.d.ts +87 -0
- package/dist/Helpers/DateTimeHelpers.js +286 -0
- package/dist/Helpers/DragHelpers.d.ts +129 -0
- package/dist/Helpers/DragHelpers.js +281 -0
- package/dist/Helpers/DropdownHelpers.d.ts +56 -0
- package/dist/Helpers/DropdownHelpers.js +180 -0
- package/dist/Helpers/FormDrafts.d.ts +18 -0
- package/dist/Helpers/FormDrafts.js +76 -0
- package/dist/Helpers/FormStore.d.ts +231 -0
- package/dist/Helpers/FormStore.js +734 -0
- package/dist/Helpers/InputParamsHelpers.d.ts +79 -0
- package/dist/Helpers/InputParamsHelpers.js +388 -0
- package/dist/Helpers/MarkdownEditing.d.ts +27 -0
- package/dist/Helpers/MarkdownEditing.js +250 -0
- package/dist/Helpers/MarkdownHelpers.d.ts +82 -0
- package/dist/Helpers/MarkdownHelpers.js +775 -0
- package/dist/Helpers/MessageBuilder.d.ts +74 -0
- package/dist/Helpers/MessageBuilder.js +254 -0
- package/dist/Helpers/NepaliCalendar.d.ts +25 -0
- package/dist/Helpers/NepaliCalendar.js +107 -0
- package/dist/Helpers/NumberHelpers.d.ts +71 -0
- package/dist/Helpers/NumberHelpers.js +202 -0
- package/dist/Helpers/Permissions.d.ts +62 -0
- package/dist/Helpers/Permissions.js +133 -0
- package/dist/Helpers/PopoverHelpers.d.ts +43 -0
- package/dist/Helpers/PopoverHelpers.js +91 -0
- package/dist/Helpers/RichTextHelpers.d.ts +77 -0
- package/dist/Helpers/RichTextHelpers.js +1072 -0
- package/dist/Helpers/RowsStore.d.ts +160 -0
- package/dist/Helpers/RowsStore.js +584 -0
- package/dist/Helpers/TableColumns.d.ts +40 -0
- package/dist/Helpers/TableColumns.js +254 -0
- package/dist/Helpers/TableExport.d.ts +30 -0
- package/dist/Helpers/TableExport.js +97 -0
- package/dist/Helpers/TableHelpers.d.ts +102 -0
- package/dist/Helpers/TableHelpers.js +281 -0
- package/dist/Helpers/TableStore.d.ts +142 -0
- package/dist/Helpers/TableStore.js +530 -0
- package/dist/Helpers/ThemeHelpers.d.ts +15 -0
- package/dist/Helpers/ThemeHelpers.js +16 -0
- package/dist/Helpers/TimeHelpers.d.ts +73 -0
- package/dist/Helpers/TimeHelpers.js +253 -0
- package/dist/Helpers/TreeStore.d.ts +188 -0
- package/dist/Helpers/TreeStore.js +577 -0
- package/dist/Helpers/ValidationHelpers.d.ts +124 -0
- package/dist/Helpers/ValidationHelpers.js +301 -0
- package/dist/Interfaces/ApiInterfaces.d.ts +149 -0
- package/dist/Interfaces/ApiInterfaces.js +24 -0
- package/dist/Interfaces/CrudInterfaces.d.ts +171 -0
- package/{src/sso-client.tsx → dist/Interfaces/CrudInterfaces.js} +1 -1
- package/dist/Interfaces/DateInterfaces.d.ts +154 -0
- package/dist/Interfaces/DateInterfaces.js +29 -0
- package/dist/Interfaces/FormInterfaces.d.ts +352 -0
- package/dist/Interfaces/FormInterfaces.js +1 -0
- package/dist/Interfaces/InputInterfaces.d.ts +141 -0
- package/dist/Interfaces/InputInterfaces.js +1 -0
- package/dist/Interfaces/MessageInterfaces.d.ts +111 -0
- package/dist/Interfaces/MessageInterfaces.js +5 -0
- package/dist/Interfaces/TableInterfaces.d.ts +291 -0
- package/dist/Interfaces/TableInterfaces.js +1 -0
- package/dist/Interfaces/ThemeInterfaces.d.ts +17 -0
- package/dist/Interfaces/ThemeInterfaces.js +17 -0
- package/dist/Interfaces/TypeInterfaces.d.ts +4 -0
- package/dist/Interfaces/TypeInterfaces.js +1 -0
- package/dist/Server.Index.d.ts +2 -0
- package/dist/Server.Index.js +5 -0
- package/dist/Services/ApiProxy.d.ts +32 -0
- package/dist/Services/ApiProxy.js +111 -0
- package/dist/Services/BrowserApi.d.ts +78 -0
- package/dist/Services/BrowserApi.js +111 -0
- package/dist/Services/ServerApi.d.ts +49 -0
- package/dist/Services/ServerApi.js +75 -0
- package/dist/Services/ThemeService.d.ts +17 -0
- package/dist/Services/ThemeService.js +83 -0
- package/dist/Shared.Index.d.ts +43 -0
- package/dist/Shared.Index.js +47 -0
- package/package.json +82 -33
- package/src/Styles/Nexus.Base.css +70 -0
- package/src/Styles/Nexus.Button.css +151 -0
- package/src/Styles/Nexus.Calendar.css +275 -0
- package/src/Styles/Nexus.Choice.css +345 -0
- package/src/Styles/Nexus.Crud.css +84 -0
- package/src/Styles/Nexus.DateTime.css +81 -0
- package/src/Styles/Nexus.Dropdown.css +774 -0
- package/src/Styles/Nexus.Editor.css +297 -0
- package/src/Styles/Nexus.Form.css +298 -0
- package/src/Styles/Nexus.Index.css +34 -0
- package/src/Styles/Nexus.Input.css +637 -0
- package/src/Styles/Nexus.Markdown.css +265 -0
- package/src/Styles/Nexus.Menu.css +249 -0
- package/src/Styles/Nexus.Popup.css +731 -0
- package/src/Styles/Nexus.RichText.css +463 -0
- package/src/Styles/Nexus.Rows.css +566 -0
- package/src/Styles/Nexus.Slider.css +278 -0
- package/src/Styles/Nexus.Tab.Buttons.css +364 -0
- package/src/Styles/Nexus.Table.css +1014 -0
- package/src/Styles/Nexus.Theme.Picker.css +159 -0
- package/src/Styles/Nexus.Themes.css +194 -0
- package/src/Styles/Nexus.Time.css +147 -0
- package/src/Styles/Nexus.Toast.css +265 -0
- package/src/Styles/Nexus.Tokens.css +72 -0
- package/src/Styles/Nexus.Tree.css +435 -0
- package/src/api-services/authentication-service.tsx +0 -23
- package/src/api-services/preference-service.tsx +0 -5
- package/src/api-services/system-service.tsx +0 -34
- package/src/client.ts +0 -31
- package/src/components/documents/button.tsx +0 -142
- package/src/components/documents/icon-box.tsx +0 -93
- package/src/components/documents/page-title.tsx +0 -7
- package/src/components/documents/tab-button.tsx +0 -172
- package/src/components/documents/tag.tsx +0 -30
- package/src/components/index.js +0 -0
- package/src/components/inputs/checkbox-input.tsx +0 -66
- package/src/components/inputs/input-box.tsx +0 -48
- package/src/components/inputs/input-element.tsx +0 -65
- package/src/components/inputs/input-form.tsx +0 -164
- package/src/components/inputs/input.tsx +0 -181
- package/src/components/inputs/number-input.tsx +0 -108
- package/src/components/inputs/radiobox-input.tsx +0 -53
- package/src/components/inputs/textarea-input.tsx +0 -47
- package/src/components/inputs/textbox-input.tsx +0 -45
- package/src/components/layouts/global-dialogbox.tsx +0 -431
- package/src/components/layouts/global-layout.tsx +0 -59
- package/src/components/layouts/layout-helpers.tsx +0 -20
- package/src/components/layouts/panels/user-panel.tsx +0 -112
- package/src/components/layouts/utility-menu.tsx +0 -49
- package/src/components/panels/theme-panel.tsx +0 -46
- package/src/helpers/bitwise-helpers.tsx +0 -11
- package/src/helpers/browser-helpers.tsx +0 -156
- package/src/helpers/datasource-helpers.tsx +0 -99
- package/src/helpers/element-helpers.tsx +0 -57
- package/src/helpers/input-helpers.tsx +0 -74
- package/src/helpers/string-helpers.tsx +0 -28
- package/src/helpers/utility-helpers.tsx +0 -45
- package/src/helpers/validation-helpers.tsx +0 -302
- package/src/index.ts +0 -26
- package/src/interface.ts +0 -19
- package/src/interfaces/auth-token-interfaces.tsx +0 -6
- package/src/interfaces/browser-interfaces.tsx +0 -36
- package/src/interfaces/button-interfaces.tsx +0 -63
- package/src/interfaces/datasource-interfaces.tsx +0 -22
- package/src/interfaces/datatable-interfaces.tsx +0 -25
- package/src/interfaces/dialogbox-interfaces.tsx +0 -5
- package/src/interfaces/exception-interfaces.tsx +0 -99
- package/src/interfaces/http-interfaces.tsx +0 -129
- package/src/interfaces/icon-interfaces.tsx +0 -129
- package/src/interfaces/input-interfaces.tsx +0 -410
- package/src/interfaces/layout-interfaces.tsx +0 -191
- package/src/interfaces/menu-interfaces.tsx +0 -49
- package/src/interfaces/message-interfaces.tsx +0 -32
- package/src/interfaces/permission-interfaces.tsx +0 -9
- package/src/interfaces/storage-interfaces.tsx +0 -5
- package/src/interfaces/system-interfaces.tsx +0 -22
- package/src/interfaces/theme-interfaces.tsx +0 -123
- package/src/interfaces/type-interfaces.tsx +0 -28
- package/src/interfaces/user-interfaces.tsx +0 -47
- package/src/nexus-client.tsx +0 -21
- package/src/nexus.environments.tsx +0 -66
- package/src/proxy-api/proxy-backend.tsx +0 -60
- package/src/proxy-api/proxy-constants.tsx +0 -28
- package/src/proxy-api/proxy-helpers.tsx +0 -91
- package/src/proxy-api-client.tsx +0 -1
- package/src/proxy-api-server.tsx +0 -2
- package/src/services/http-client-services.tsx +0 -75
- package/src/services/http-services.tsx +0 -158
- package/src/services/loader-service.tsx +0 -185
- package/src/services/localstorage-service.tsx +0 -119
- package/src/services/message-services.tsx +0 -383
- package/src/services/theme-service.tsx +0 -161
- package/src/services/user-services.tsx +0 -10
- package/src/sso-config/auth-token-validation.ts +0 -16
- package/src/sso-config/callback-route.tsx +0 -35
- package/src/sso-config/cookie-encryption.ts +0 -51
- package/src/sso-config/cookie-helpers.tsx +0 -102
- package/src/sso-config/forgot-password-route.tsx +0 -36
- package/src/sso-config/oauth-callback-state.ts +0 -61
- package/src/sso-config/pkce-helpers.ts +0 -9
- package/src/sso-config/provider-complete-route.tsx +0 -18
- package/src/sso-config/redirect-context-actions.tsx +0 -45
- package/src/sso-config/redirect-context-helpers.tsx +0 -159
- package/src/sso-config/redirect-context-persist.tsx +0 -18
- package/src/sso-config/redirect-context-route.tsx +0 -45
- package/src/sso-config/refresh-route.tsx +0 -23
- package/src/sso-config/sign-in-route.tsx +0 -74
- package/src/sso-config/sign-out-route.tsx +0 -57
- package/src/sso-config/sign-up-route.tsx +0 -57
- package/src/sso-config/sso-backend-flow.ts +0 -38
- package/src/sso-config/sso-complete-route.tsx +0 -50
- package/src/sso-config/sso-config.ts +0 -59
- package/src/sso-config/sso-interfaces.tsx +0 -99
- package/src/sso-config/sso-response-helpers.tsx +0 -20
- package/src/sso-server.tsx +0 -21
- package/src/styles/nexus.animation.css +0 -269
- package/src/styles/nexus.core.css +0 -119
- package/src/styles/nexus.dialog.css +0 -144
- package/src/styles/nexus.icon.css +0 -51
- package/src/styles/nexus.input.css +0 -207
- package/src/styles/nexus.loader.css +0 -11
- package/src/styles/nexus.logic.css +0 -47
- package/src/styles/nexus.utility.css +0 -347
|
@@ -0,0 +1,839 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { iconAlertCircle, iconChevronDown, iconLoader2, iconX, InlineIcon } from "nexus-icons";
|
|
4
|
+
import { isValidElement, useDeferredValue, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, } from "react";
|
|
5
|
+
import { cx } from "../../Helpers/ClassHelpers.js";
|
|
6
|
+
import { columnTrack, defaultSearchKeys, filterItems, foldSearchText, indexItems, isDisabledItem, isInactiveItem, itemLabel, itemValue, matchesSearch, optionKey, readField, searchWords, toValueList, } from "../../Helpers/DropdownHelpers.js";
|
|
7
|
+
import { followAnchor, isPopoverOpen, placeListPanel } from "../../Helpers/PopoverHelpers.js";
|
|
8
|
+
import { DEFAULT_FIELD_NAME, validateSelection } from "../../Helpers/ValidationHelpers.js";
|
|
9
|
+
import { Button } from "../Documents/Button.js";
|
|
10
|
+
import { DropdownChip, DropdownMedia, DropdownOption, DropdownTag, hasIcon } from "./DropdownParts.js";
|
|
11
|
+
import { Field } from "./Field.js";
|
|
12
|
+
import { useInputField } from "./InputField.js";
|
|
13
|
+
import { useReadOnlyNotice } from "./ReadOnlyNotice.js";
|
|
14
|
+
/** Rows a local list renders at first, and adds each time it scrolls near its end, so 10,000 options open at once. */
|
|
15
|
+
const RENDER_STEP = 100;
|
|
16
|
+
/** Closer to the end of the list than this, in pixels, renders or loads more. */
|
|
17
|
+
const NEAR_END = 160;
|
|
18
|
+
/** How long typing pauses before a server search starts, in milliseconds. */
|
|
19
|
+
const SEARCH_DELAY = 250;
|
|
20
|
+
/** Letters typed within this time, in milliseconds, add up to one search in a list without a search box. */
|
|
21
|
+
const TYPEAHEAD_DELAY = 600;
|
|
22
|
+
/** The tallest the list's panel grows, in pixels. */
|
|
23
|
+
const PANEL_MAX_HEIGHT = 360;
|
|
24
|
+
const PAGE_STEP = 10;
|
|
25
|
+
/** Values named in the screen reader's "Selected: …" text and the "+12 more" chip's tooltip before "and N more". */
|
|
26
|
+
const NAMES_LISTED = 20;
|
|
27
|
+
const SKELETON_WIDTHS = ["72%", "54%", "64%", "46%"];
|
|
28
|
+
const IDLE_LIST = { search: "", loadKey: undefined, items: [], page: 0, hasMore: false, total: undefined, status: "idle", errorPage: 1, firstKeys: new Set() };
|
|
29
|
+
export function Dropdown(props) {
|
|
30
|
+
const { label, hint, error, design = "standard", size = "md", icon, options, loadOptions, loadKey, pageSize = 50, selectedOptions, resolveOptions, valueKey = "value", labelKey = "label", descriptionKey = "description", iconKey = "icon", columns, searchKeys, isOptionDisabled = isDisabledItem, isOptionInactive = isInactiveItem, searchable: searchableProp, clearable = true, loading = false, emptyMessage = "No options", renderValue, panelWidth, readOnlyMessage, fieldName, validateOn, className, inputClassName, style, required, validate, name, form, placeholder, disabled, readOnly, ref, onBlur, onFocus, onKeyDown, onClick, onInvalid, multiple: multipleProp, value: valueProp, defaultValue, onValueChange, min, max, selectAll = true, maxChips = 10, ...inputProps } = props;
|
|
31
|
+
const multiple = multipleProp === true;
|
|
32
|
+
const controlled = valueProp !== undefined;
|
|
33
|
+
const editable = !disabled && !readOnly;
|
|
34
|
+
const searchable = searchableProp ?? (Boolean(loadOptions) || (options?.length ?? 0) > 8);
|
|
35
|
+
const messageName = fieldName ?? (typeof label === "string" ? label : DEFAULT_FIELD_NAME);
|
|
36
|
+
// Values
|
|
37
|
+
const [ownValues, setOwnValues] = useState(() => toValueList(defaultValue, multiple));
|
|
38
|
+
const values = controlled ? toValueList(valueProp, multiple) : ownValues;
|
|
39
|
+
const valuesKey = values.map(optionKey).join("\n");
|
|
40
|
+
const valuesRef = useRef(values);
|
|
41
|
+
valuesRef.current = values;
|
|
42
|
+
const selectedKeys = useMemo(() => new Set(values.map(optionKey)), [valuesKey]);
|
|
43
|
+
const editedRef = useRef(false);
|
|
44
|
+
// Items: the options, pages from the server and items resolved for values (both kept by key), and selectedOptions.
|
|
45
|
+
const optionIndex = useMemo(() => (options ? indexItems(options, valueKey) : null), [options, valueKey]);
|
|
46
|
+
const selectedIndex = useMemo(() => (selectedOptions ? indexItems(selectedOptions, valueKey) : null), [selectedOptions, valueKey]);
|
|
47
|
+
const knownRef = useRef(new Map());
|
|
48
|
+
const findItem = (key) => optionIndex?.get(key) ?? knownRef.current.get(key) ?? selectedIndex?.get(key) ?? null;
|
|
49
|
+
const labelOf = (item) => itemLabel(item, labelKey, valueKey);
|
|
50
|
+
const listId = `${useId()}-list`;
|
|
51
|
+
const selectionId = `${listId}-selection`;
|
|
52
|
+
const panelRef = useRef(null);
|
|
53
|
+
const listRef = useRef(null);
|
|
54
|
+
/** Places the open panel against the box; null while closed. */
|
|
55
|
+
const placeRef = useRef(null);
|
|
56
|
+
const mountedRef = useRef(true);
|
|
57
|
+
const [open, setOpen] = useState(false);
|
|
58
|
+
const [search, setSearch] = useState("");
|
|
59
|
+
const [active, setActive] = useState(-1);
|
|
60
|
+
const [renderLimit, setRenderLimit] = useState(RENDER_STEP);
|
|
61
|
+
const [pinned, setPinned] = useState([]);
|
|
62
|
+
const scrollToActiveRef = useRef(false);
|
|
63
|
+
const typeaheadRef = useRef({ text: "", time: 0, pending: "" });
|
|
64
|
+
const field = useInputField({
|
|
65
|
+
...props,
|
|
66
|
+
"aria-describedby": cx(props["aria-describedby"], selectionId),
|
|
67
|
+
onChange: handleSearchChange,
|
|
68
|
+
}, {
|
|
69
|
+
check: (_input, fieldName) => validateSelection(valuesRef.current, { required, min, max, validate }, fieldName),
|
|
70
|
+
checkKeys: [valuesKey, required, min, max, validate],
|
|
71
|
+
onReset: () => {
|
|
72
|
+
// Back to the values the field started with. A controlled dropdown is reset by its owner.
|
|
73
|
+
editedRef.current = false;
|
|
74
|
+
if (controlled)
|
|
75
|
+
return;
|
|
76
|
+
const initial = toValueList(defaultValue, multiple);
|
|
77
|
+
valuesRef.current = initial;
|
|
78
|
+
setOwnValues(initial);
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
const readOnlyNotice = useReadOnlyNotice(Boolean(readOnly) && !disabled, field.elementRef, readOnlyMessage ?? (multiple ? "Read-only: these choices cannot be changed." : "Read-only: this choice cannot be changed."), {
|
|
82
|
+
keys: ["ArrowDown", "ArrowUp"],
|
|
83
|
+
});
|
|
84
|
+
// A default value that arrives after mount (from an API, say) replaces the value, until the user changes it.
|
|
85
|
+
const defaultKey = toValueList(defaultValue, multiple).map(optionKey).join("\n");
|
|
86
|
+
useLayoutEffect(() => {
|
|
87
|
+
if (controlled || editedRef.current)
|
|
88
|
+
return;
|
|
89
|
+
const initial = toValueList(defaultValue, multiple);
|
|
90
|
+
if (initial.map(optionKey).join("\n") === valuesRef.current.map(optionKey).join("\n"))
|
|
91
|
+
return;
|
|
92
|
+
valuesRef.current = initial;
|
|
93
|
+
setOwnValues(initial);
|
|
94
|
+
}, [defaultKey]);
|
|
95
|
+
// Resolving values that nothing describes yet, once per value.
|
|
96
|
+
const requestedRef = useRef(new Set());
|
|
97
|
+
const pendingRef = useRef(new Set());
|
|
98
|
+
const [, setResolved] = useState(0);
|
|
99
|
+
useEffect(() => {
|
|
100
|
+
if (!resolveOptions || loading)
|
|
101
|
+
return;
|
|
102
|
+
const missing = valuesRef.current.filter(value => {
|
|
103
|
+
const key = optionKey(value);
|
|
104
|
+
return !findItem(key) && !requestedRef.current.has(key);
|
|
105
|
+
});
|
|
106
|
+
if (missing.length === 0)
|
|
107
|
+
return;
|
|
108
|
+
const keys = missing.map(optionKey);
|
|
109
|
+
for (const key of keys) {
|
|
110
|
+
requestedRef.current.add(key);
|
|
111
|
+
pendingRef.current.add(key);
|
|
112
|
+
}
|
|
113
|
+
setResolved(count => count + 1);
|
|
114
|
+
Promise.resolve()
|
|
115
|
+
.then(() => resolveOptions(missing))
|
|
116
|
+
.then(items => {
|
|
117
|
+
for (const item of items ?? []) {
|
|
118
|
+
const key = optionKey(itemValue(item, valueKey));
|
|
119
|
+
if (key)
|
|
120
|
+
knownRef.current.set(key, item);
|
|
121
|
+
}
|
|
122
|
+
}, () => undefined)
|
|
123
|
+
.finally(() => {
|
|
124
|
+
for (const key of keys)
|
|
125
|
+
pendingRef.current.delete(key);
|
|
126
|
+
if (mountedRef.current)
|
|
127
|
+
setResolved(count => count + 1);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
// Server pages
|
|
131
|
+
const [server, setServer] = useState(IDLE_LIST);
|
|
132
|
+
const [query, setQuery] = useState("");
|
|
133
|
+
const requestRef = useRef({ id: 0, controller: null });
|
|
134
|
+
const loadRef = useRef(loadOptions);
|
|
135
|
+
useLayoutEffect(() => {
|
|
136
|
+
loadRef.current = loadOptions;
|
|
137
|
+
});
|
|
138
|
+
useEffect(() => {
|
|
139
|
+
mountedRef.current = true;
|
|
140
|
+
return () => {
|
|
141
|
+
mountedRef.current = false;
|
|
142
|
+
requestRef.current.controller?.abort();
|
|
143
|
+
};
|
|
144
|
+
}, []);
|
|
145
|
+
function loadPage(searchText, page) {
|
|
146
|
+
const load = loadRef.current;
|
|
147
|
+
if (!load)
|
|
148
|
+
return;
|
|
149
|
+
requestRef.current.controller?.abort();
|
|
150
|
+
const controller = new AbortController();
|
|
151
|
+
const id = ++requestRef.current.id;
|
|
152
|
+
requestRef.current.controller = controller;
|
|
153
|
+
const key = loadKey;
|
|
154
|
+
setServer(list => (page === 1 ? { ...list, search: searchText, loadKey: key, status: "loading" } : { ...list, status: "more" }));
|
|
155
|
+
Promise.resolve()
|
|
156
|
+
.then(() => load({ search: searchText, page, pageSize, skip: (page - 1) * pageSize, signal: controller.signal }))
|
|
157
|
+
.then(result => {
|
|
158
|
+
if (id !== requestRef.current.id || !mountedRef.current)
|
|
159
|
+
return;
|
|
160
|
+
const items = [...(result?.items ?? [])];
|
|
161
|
+
for (const item of items) {
|
|
162
|
+
const itemKey = optionKey(itemValue(item, valueKey));
|
|
163
|
+
if (itemKey)
|
|
164
|
+
knownRef.current.set(itemKey, item);
|
|
165
|
+
}
|
|
166
|
+
const firstKeys = page === 1 ? new Set(items.map(item => optionKey(itemValue(item, valueKey)))) : null;
|
|
167
|
+
setServer(list => ({
|
|
168
|
+
search: searchText,
|
|
169
|
+
loadKey: key,
|
|
170
|
+
items: page === 1 ? items : appendNew(list.items, items, valueKey),
|
|
171
|
+
page,
|
|
172
|
+
hasMore: result?.hasMore ?? items.length >= pageSize,
|
|
173
|
+
total: result?.total,
|
|
174
|
+
status: "ready",
|
|
175
|
+
errorPage: 1,
|
|
176
|
+
firstKeys: firstKeys ?? list.firstKeys,
|
|
177
|
+
}));
|
|
178
|
+
// A fresh first page decides again which current values the list already shows.
|
|
179
|
+
if (firstKeys)
|
|
180
|
+
setPinned(pinnedValues(valuesRef.current, firstKeys));
|
|
181
|
+
}, () => {
|
|
182
|
+
if (id !== requestRef.current.id || !mountedRef.current || controller.signal.aborted)
|
|
183
|
+
return;
|
|
184
|
+
setServer(list => ({ ...list, status: "error", errorPage: page }));
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
function loadMore() {
|
|
188
|
+
if (loadOptions && server.status === "ready" && server.hasMore)
|
|
189
|
+
loadPage(server.search, server.page + 1);
|
|
190
|
+
}
|
|
191
|
+
// A new loadKey drops what was loaded for the old one.
|
|
192
|
+
const loadKeyRef = useRef(loadKey);
|
|
193
|
+
useEffect(() => {
|
|
194
|
+
if (Object.is(loadKeyRef.current, loadKey))
|
|
195
|
+
return;
|
|
196
|
+
loadKeyRef.current = loadKey;
|
|
197
|
+
requestRef.current.controller?.abort();
|
|
198
|
+
requestRef.current.id++;
|
|
199
|
+
setServer(IDLE_LIST);
|
|
200
|
+
}, [loadKey]);
|
|
201
|
+
// The server search follows typing after a pause.
|
|
202
|
+
useEffect(() => {
|
|
203
|
+
if (!loadOptions)
|
|
204
|
+
return;
|
|
205
|
+
const next = search.trim();
|
|
206
|
+
if (next === query)
|
|
207
|
+
return;
|
|
208
|
+
const timer = setTimeout(() => setQuery(next), next ? SEARCH_DELAY : 0);
|
|
209
|
+
return () => clearTimeout(timer);
|
|
210
|
+
}, [search]);
|
|
211
|
+
// An open list loads the first page of its search, unless it has it or is loading it.
|
|
212
|
+
useEffect(() => {
|
|
213
|
+
if (!open || !loadOptions)
|
|
214
|
+
return;
|
|
215
|
+
const current = (server.status === "ready" || server.status === "loading" || server.status === "more") && server.search === query && Object.is(server.loadKey, loadKey);
|
|
216
|
+
if (!current)
|
|
217
|
+
loadPage(query, 1);
|
|
218
|
+
}, [open, query, loadKey]);
|
|
219
|
+
/**
|
|
220
|
+
* Current values that show at the top of the list: values nothing describes, inactive items, and items the options
|
|
221
|
+
* do not hold (for a server list: not on the first page of the search).
|
|
222
|
+
*/
|
|
223
|
+
function pinnedValues(current, firstKeys) {
|
|
224
|
+
return current.filter(value => {
|
|
225
|
+
const key = optionKey(value);
|
|
226
|
+
const item = findItem(key);
|
|
227
|
+
if (!item || isOptionInactive(item))
|
|
228
|
+
return true;
|
|
229
|
+
return loadOptions ? !firstKeys.has(key) : !optionIndex?.has(key);
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
function stateOf(key, item) {
|
|
233
|
+
// A server list may hold a bare value on a page not loaded yet: it is "Not found" only once resolveOptions did not return it.
|
|
234
|
+
if (!item)
|
|
235
|
+
return loadOptions && !(resolveOptions && requestedRef.current.has(key)) ? null : "unknown";
|
|
236
|
+
if (isOptionInactive(item))
|
|
237
|
+
return "inactive";
|
|
238
|
+
if (!loadOptions && !loading && !optionIndex?.has(key))
|
|
239
|
+
return "unavailable";
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
// A value resolveOptions has yet to look up counts as pending from the first render (the server's HTML too), so its
|
|
243
|
+
// bare id never flashes before the lookup starts.
|
|
244
|
+
const isPending = (key, item) => !item && (pendingRef.current.has(key) || loading || (Boolean(resolveOptions) && !requestedRef.current.has(key)));
|
|
245
|
+
/** Whether a row's item can be chosen. A chosen row can always be removed. */
|
|
246
|
+
function canPick(key, item) {
|
|
247
|
+
if (!item || isOptionDisabled(item) || isOptionInactive(item))
|
|
248
|
+
return false;
|
|
249
|
+
return Boolean(loadOptions) || Boolean(optionIndex?.has(key));
|
|
250
|
+
}
|
|
251
|
+
// Rows, only while open
|
|
252
|
+
const deferredSearch = useDeferredValue(search);
|
|
253
|
+
const keysForSearch = useMemo(() => searchKeys ?? defaultSearchKeys(labelKey, descriptionKey, valueKey, columns), [searchKeys, labelKey, descriptionKey, valueKey, columns]);
|
|
254
|
+
const words = useMemo(() => searchWords(deferredSearch), [deferredSearch]);
|
|
255
|
+
const localMatches = useMemo(() => (open && options && !loadOptions ? filterItems(options, deferredSearch, keysForSearch) : []), [open, options, Boolean(loadOptions), deferredSearch, keysForSearch]);
|
|
256
|
+
const sourceItems = loadOptions ? server.items : localMatches;
|
|
257
|
+
const offered = useMemo(() => {
|
|
258
|
+
if (!open)
|
|
259
|
+
return [];
|
|
260
|
+
const seen = new Set();
|
|
261
|
+
return sourceItems.filter(item => {
|
|
262
|
+
const key = optionKey(itemValue(item, valueKey));
|
|
263
|
+
if (!key || seen.has(key) || isOptionInactive(item))
|
|
264
|
+
return false;
|
|
265
|
+
seen.add(key);
|
|
266
|
+
return true;
|
|
267
|
+
});
|
|
268
|
+
}, [open, sourceItems, valueKey, isOptionInactive]);
|
|
269
|
+
const atMax = multiple && max !== undefined && values.length >= max;
|
|
270
|
+
const rows = [];
|
|
271
|
+
if (open) {
|
|
272
|
+
for (const value of pinned) {
|
|
273
|
+
const key = optionKey(value);
|
|
274
|
+
const item = findItem(key);
|
|
275
|
+
const matches = words.length === 0 || (item ? matchesSearch(item, words, keysForSearch) : words.every(word => foldSearchText(key).includes(word)));
|
|
276
|
+
if (matches)
|
|
277
|
+
rows.push({ key, value, item, pinned: true });
|
|
278
|
+
}
|
|
279
|
+
const limit = loadOptions ? offered.length : Math.min(offered.length, renderLimit);
|
|
280
|
+
for (let index = 0; index < limit; index++) {
|
|
281
|
+
const item = offered[index];
|
|
282
|
+
rows.push({ key: optionKey(itemValue(item, valueKey)), value: itemValue(item, valueKey), item, pinned: false });
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
const pinnedCount = rows.findIndex(row => !row.pinned) < 0 ? rows.length : rows.findIndex(row => !row.pinned);
|
|
286
|
+
const rowDisabled = (row) => !selectedKeys.has(row.key) && (!canPick(row.key, row.item) || atMax);
|
|
287
|
+
const firstEnabled = (from, direction) => {
|
|
288
|
+
for (let index = from; index >= 0 && index < rows.length; index += direction)
|
|
289
|
+
if (!rowDisabled(rows[index]))
|
|
290
|
+
return index;
|
|
291
|
+
return -1;
|
|
292
|
+
};
|
|
293
|
+
// Without a move of its own, the highlight rests on the chosen row (a single choice, before any search) or the first that can be picked.
|
|
294
|
+
const fallbackActive = () => {
|
|
295
|
+
if (!multiple && !deferredSearch.trim()) {
|
|
296
|
+
const chosen = rows.findIndex(row => selectedKeys.has(row.key));
|
|
297
|
+
if (chosen >= 0)
|
|
298
|
+
return chosen;
|
|
299
|
+
}
|
|
300
|
+
return firstEnabled(0, 1);
|
|
301
|
+
};
|
|
302
|
+
const activeIndex = !open ? -1 : active >= 0 && active < rows.length ? active : fallbackActive();
|
|
303
|
+
const staleRows = Boolean(loadOptions) && server.status === "loading" && server.items.length > 0;
|
|
304
|
+
// An open server list that has not started its first request yet is loading too: its placeholder rows already show.
|
|
305
|
+
const busy = loading || pendingRef.current.size > 0 || (Boolean(loadOptions) && (server.status === "loading" || server.status === "more" || (open && server.status === "idle")));
|
|
306
|
+
// Select all and Clear act on every option the list holds for the search, rendered yet or not, and on the current
|
|
307
|
+
// values shown at its top. The options are split once per list and selection, not on every highlight move.
|
|
308
|
+
const showBulk = open && multiple && selectAll;
|
|
309
|
+
const offeredSplit = useMemo(() => {
|
|
310
|
+
if (!showBulk)
|
|
311
|
+
return null;
|
|
312
|
+
const keys = new Set();
|
|
313
|
+
const addable = [];
|
|
314
|
+
const chosen = [];
|
|
315
|
+
for (const item of offered) {
|
|
316
|
+
const value = itemValue(item, valueKey);
|
|
317
|
+
const key = optionKey(value);
|
|
318
|
+
keys.add(key);
|
|
319
|
+
if (selectedKeys.has(key))
|
|
320
|
+
chosen.push(key);
|
|
321
|
+
else if (!isOptionDisabled(item))
|
|
322
|
+
addable.push(value);
|
|
323
|
+
}
|
|
324
|
+
return { keys, addable, chosen };
|
|
325
|
+
}, [showBulk, offered, selectedKeys, valueKey, isOptionDisabled]);
|
|
326
|
+
let bulk = null;
|
|
327
|
+
if (offeredSplit) {
|
|
328
|
+
const addable = [...offeredSplit.addable];
|
|
329
|
+
const chosen = [...offeredSplit.chosen];
|
|
330
|
+
for (const row of rows.slice(0, pinnedCount)) {
|
|
331
|
+
if (offeredSplit.keys.has(row.key))
|
|
332
|
+
continue;
|
|
333
|
+
if (selectedKeys.has(row.key))
|
|
334
|
+
chosen.push(row.key);
|
|
335
|
+
else if (canPick(row.key, row.item))
|
|
336
|
+
addable.push(row.value);
|
|
337
|
+
}
|
|
338
|
+
bulk = { addable, chosen };
|
|
339
|
+
}
|
|
340
|
+
function commit(next) {
|
|
341
|
+
editedRef.current = true;
|
|
342
|
+
valuesRef.current = next;
|
|
343
|
+
if (!controlled)
|
|
344
|
+
setOwnValues(next);
|
|
345
|
+
field.change();
|
|
346
|
+
if (multiple) {
|
|
347
|
+
onValueChange?.(next, next.map(value => findItem(optionKey(value))));
|
|
348
|
+
}
|
|
349
|
+
else {
|
|
350
|
+
const value = next[0] ?? null;
|
|
351
|
+
onValueChange?.(value, value === null ? null : findItem(optionKey(value)));
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
function openPanel() {
|
|
355
|
+
if (!editable || open)
|
|
356
|
+
return;
|
|
357
|
+
field.elementRef.current?.focus();
|
|
358
|
+
// Shown before its rows render, so the list can scroll to the chosen row as it mounts. Nothing paints in between.
|
|
359
|
+
const panel = panelRef.current;
|
|
360
|
+
if (panel && !isPopoverOpen(panel))
|
|
361
|
+
panel.showPopover();
|
|
362
|
+
setPinned(pinnedValues(valuesRef.current, server.firstKeys));
|
|
363
|
+
// A single choice far down a long list is rendered, so the list can open on it.
|
|
364
|
+
const chosenKey = !multiple && !loadOptions && options && valuesRef.current.length > 0 ? optionKey(valuesRef.current[0]) : null;
|
|
365
|
+
const chosenIndex = chosenKey === null ? -1 : options.findIndex(item => optionKey(itemValue(item, valueKey)) === chosenKey);
|
|
366
|
+
setRenderLimit(chosenIndex < RENDER_STEP ? RENDER_STEP : (Math.ceil((chosenIndex + 1) / RENDER_STEP) + 1) * RENDER_STEP);
|
|
367
|
+
setActive(-1);
|
|
368
|
+
setOpen(true);
|
|
369
|
+
}
|
|
370
|
+
/** Closes the list and drops the typed search. With `returnFocus`, focus inside the panel goes back to the box. */
|
|
371
|
+
function closePanel(returnFocus = false) {
|
|
372
|
+
const panel = panelRef.current;
|
|
373
|
+
const input = field.elementRef.current;
|
|
374
|
+
const focusInPanel = Boolean(panel?.contains(document.activeElement));
|
|
375
|
+
if (isPopoverOpen(panel))
|
|
376
|
+
panel.hidePopover();
|
|
377
|
+
setOpen(false);
|
|
378
|
+
setActive(-1);
|
|
379
|
+
if (input && input.value)
|
|
380
|
+
input.value = "";
|
|
381
|
+
setSearch("");
|
|
382
|
+
if (returnFocus && focusInPanel)
|
|
383
|
+
input?.focus();
|
|
384
|
+
}
|
|
385
|
+
function pick(row) {
|
|
386
|
+
const chosen = selectedKeys.has(row.key);
|
|
387
|
+
if (!chosen && rowDisabled(row))
|
|
388
|
+
return;
|
|
389
|
+
if (multiple) {
|
|
390
|
+
commit(chosen ? valuesRef.current.filter(value => optionKey(value) !== row.key) : [...valuesRef.current, row.value]);
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
if (!chosen)
|
|
394
|
+
commit([row.value]);
|
|
395
|
+
closePanel(true);
|
|
396
|
+
}
|
|
397
|
+
function remove(key) {
|
|
398
|
+
commit(valuesRef.current.filter(value => optionKey(value) !== key));
|
|
399
|
+
}
|
|
400
|
+
function clear() {
|
|
401
|
+
commit([]);
|
|
402
|
+
field.elementRef.current?.focus();
|
|
403
|
+
}
|
|
404
|
+
/** Adds every option the list shows that is not chosen yet, after the values already chosen. */
|
|
405
|
+
function selectShown() {
|
|
406
|
+
if (!bulk)
|
|
407
|
+
return;
|
|
408
|
+
const current = new Set(valuesRef.current.map(optionKey));
|
|
409
|
+
commit([...valuesRef.current, ...bulk.addable.filter(value => !current.has(optionKey(value)))]);
|
|
410
|
+
}
|
|
411
|
+
/** Removes the chosen values the list shows. Values a search hides stay. */
|
|
412
|
+
function clearShown() {
|
|
413
|
+
if (!bulk)
|
|
414
|
+
return;
|
|
415
|
+
const shown = new Set(bulk.chosen);
|
|
416
|
+
commit(valuesRef.current.filter(value => !shown.has(optionKey(value))));
|
|
417
|
+
}
|
|
418
|
+
function handleSearchChange(event) {
|
|
419
|
+
const input = event.currentTarget;
|
|
420
|
+
// Without a search box, typed letters jump to an option (see the key handler) and never stay in the box.
|
|
421
|
+
if (!searchable) {
|
|
422
|
+
input.value = "";
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
setSearch(input.value);
|
|
426
|
+
setActive(-1);
|
|
427
|
+
setRenderLimit(RENDER_STEP);
|
|
428
|
+
if (!open)
|
|
429
|
+
openPanel();
|
|
430
|
+
}
|
|
431
|
+
/** Moves the highlight by `delta` rows (±Infinity: the first or last), past rows that cannot be picked. */
|
|
432
|
+
function moveActive(delta) {
|
|
433
|
+
if (rows.length === 0)
|
|
434
|
+
return;
|
|
435
|
+
let target;
|
|
436
|
+
if (delta === Infinity)
|
|
437
|
+
target = firstEnabled(rows.length - 1, -1);
|
|
438
|
+
else if (delta === -Infinity)
|
|
439
|
+
target = firstEnabled(0, 1);
|
|
440
|
+
else {
|
|
441
|
+
const direction = delta > 0 ? 1 : -1;
|
|
442
|
+
const from = activeIndex < 0 ? (direction > 0 ? 0 : rows.length - 1) : Math.max(0, Math.min(rows.length - 1, activeIndex + delta));
|
|
443
|
+
target = firstEnabled(from, direction);
|
|
444
|
+
// A page jump past the last row that can be picked stops on it.
|
|
445
|
+
if (target < 0 && Math.abs(delta) > 1)
|
|
446
|
+
target = firstEnabled(from, direction > 0 ? -1 : 1);
|
|
447
|
+
}
|
|
448
|
+
if (target < 0)
|
|
449
|
+
return;
|
|
450
|
+
scrollToActiveRef.current = true;
|
|
451
|
+
setActive(target);
|
|
452
|
+
if (target >= rows.length - 5) {
|
|
453
|
+
if (loadOptions)
|
|
454
|
+
loadMore();
|
|
455
|
+
else if (renderLimit < offered.length)
|
|
456
|
+
setRenderLimit(limit => limit + RENDER_STEP);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
/** Arrow keys, Page Up and Down, Home and End, and Enter in an open list. Returns whether the key was used. */
|
|
460
|
+
function navigate(event) {
|
|
461
|
+
const { key } = event;
|
|
462
|
+
const input = field.elementRef.current;
|
|
463
|
+
const caretKeys = searchable && Boolean(input?.value);
|
|
464
|
+
switch (key) {
|
|
465
|
+
case "ArrowDown":
|
|
466
|
+
moveActive(1);
|
|
467
|
+
return true;
|
|
468
|
+
case "ArrowUp":
|
|
469
|
+
moveActive(-1);
|
|
470
|
+
return true;
|
|
471
|
+
case "PageDown":
|
|
472
|
+
moveActive(PAGE_STEP);
|
|
473
|
+
return true;
|
|
474
|
+
case "PageUp":
|
|
475
|
+
moveActive(-PAGE_STEP);
|
|
476
|
+
return true;
|
|
477
|
+
case "Home":
|
|
478
|
+
case "End":
|
|
479
|
+
// In typed text these move the caret.
|
|
480
|
+
if (caretKeys)
|
|
481
|
+
return false;
|
|
482
|
+
moveActive(key === "End" ? Infinity : -Infinity);
|
|
483
|
+
return true;
|
|
484
|
+
case "Enter": {
|
|
485
|
+
const row = rows[activeIndex];
|
|
486
|
+
if (row && !staleRows)
|
|
487
|
+
pick(row);
|
|
488
|
+
return true;
|
|
489
|
+
}
|
|
490
|
+
default:
|
|
491
|
+
return false;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
function handleKeyDown(event) {
|
|
495
|
+
readOnlyNotice.onKeyDown(event);
|
|
496
|
+
const { key } = event;
|
|
497
|
+
const input = event.currentTarget;
|
|
498
|
+
if (editable) {
|
|
499
|
+
if (open && navigate(event)) {
|
|
500
|
+
event.preventDefault();
|
|
501
|
+
}
|
|
502
|
+
else if (!open && (key === "ArrowDown" || key === "ArrowUp")) {
|
|
503
|
+
event.preventDefault();
|
|
504
|
+
openPanel();
|
|
505
|
+
}
|
|
506
|
+
else if (key === "Escape" && open) {
|
|
507
|
+
// Closes the list only: the page (a dialog, say) does not see this Esc.
|
|
508
|
+
event.preventDefault();
|
|
509
|
+
event.stopPropagation();
|
|
510
|
+
closePanel();
|
|
511
|
+
}
|
|
512
|
+
else if (key === "Escape" && input.value) {
|
|
513
|
+
event.preventDefault();
|
|
514
|
+
event.stopPropagation();
|
|
515
|
+
input.value = "";
|
|
516
|
+
setSearch("");
|
|
517
|
+
}
|
|
518
|
+
else if (key === "Tab" && open) {
|
|
519
|
+
closePanel();
|
|
520
|
+
}
|
|
521
|
+
else if (key === "Backspace" && !input.value && values.length > 0 && (multiple || clearable)) {
|
|
522
|
+
// An empty search box: Backspace removes the last chip, or the single value.
|
|
523
|
+
commit(valuesRef.current.slice(0, -1));
|
|
524
|
+
}
|
|
525
|
+
else if (!searchable && (key === " " || key === "Enter") && !open) {
|
|
526
|
+
event.preventDefault();
|
|
527
|
+
openPanel();
|
|
528
|
+
}
|
|
529
|
+
else if (!searchable && key === " " && open) {
|
|
530
|
+
event.preventDefault();
|
|
531
|
+
const row = rows[activeIndex];
|
|
532
|
+
if (row)
|
|
533
|
+
pick(row);
|
|
534
|
+
}
|
|
535
|
+
else if (!searchable && key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {
|
|
536
|
+
event.preventDefault();
|
|
537
|
+
typeahead(key);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
onKeyDown?.(event);
|
|
541
|
+
}
|
|
542
|
+
/** Jumps to the next option whose label starts with the letters typed in quick succession. */
|
|
543
|
+
function typeahead(char) {
|
|
544
|
+
const state = typeaheadRef.current;
|
|
545
|
+
const now = Date.now();
|
|
546
|
+
state.text = now - state.time > TYPEAHEAD_DELAY ? char : state.text + char;
|
|
547
|
+
state.time = now;
|
|
548
|
+
state.pending = state.text;
|
|
549
|
+
if (!open)
|
|
550
|
+
openPanel();
|
|
551
|
+
else
|
|
552
|
+
applyTypeahead();
|
|
553
|
+
}
|
|
554
|
+
function applyTypeahead() {
|
|
555
|
+
const state = typeaheadRef.current;
|
|
556
|
+
const typed = foldSearchText(state.pending);
|
|
557
|
+
state.pending = "";
|
|
558
|
+
if (!typed || rows.length === 0)
|
|
559
|
+
return;
|
|
560
|
+
// The same letter pressed again ("s", "s") cycles through the options that start with it, as in a native select.
|
|
561
|
+
const text = [...typed].every(char => char === typed[0]) ? typed[0] : typed;
|
|
562
|
+
// One letter again moves on to the next option with it; more letters keep the current one while it still matches.
|
|
563
|
+
// Before any move, "current" is the chosen row, or nothing, so the first letter finds the first match.
|
|
564
|
+
const current = active >= 0 && active < rows.length ? active : rows.findIndex(row => selectedKeys.has(row.key));
|
|
565
|
+
const start = text.length === 1 ? current + 1 : Math.max(current, 0);
|
|
566
|
+
for (let step = 0; step < rows.length; step++) {
|
|
567
|
+
const index = (start + step) % rows.length;
|
|
568
|
+
const row = rows[index];
|
|
569
|
+
if (rowDisabled(row))
|
|
570
|
+
continue;
|
|
571
|
+
if (foldSearchText(row.item ? labelOf(row.item) : row.key).startsWith(text)) {
|
|
572
|
+
scrollToActiveRef.current = true;
|
|
573
|
+
setActive(index);
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
function handleClick(event) {
|
|
579
|
+
if (!open)
|
|
580
|
+
openPanel();
|
|
581
|
+
readOnlyNotice.onClick(event);
|
|
582
|
+
onClick?.(event);
|
|
583
|
+
}
|
|
584
|
+
function handleBlur(event) {
|
|
585
|
+
// Focus moving anywhere but the panel closes it.
|
|
586
|
+
if (open && !(event.relatedTarget instanceof Node && panelRef.current?.contains(event.relatedTarget)))
|
|
587
|
+
closePanel();
|
|
588
|
+
readOnlyNotice.hide();
|
|
589
|
+
field.controlProps.onBlur(event);
|
|
590
|
+
}
|
|
591
|
+
// A press on the chips or the space around the text focuses the box and opens the list, like a native select.
|
|
592
|
+
function handleControlMouseDown(event) {
|
|
593
|
+
if (disabled)
|
|
594
|
+
return;
|
|
595
|
+
const target = event.target;
|
|
596
|
+
const input = field.elementRef.current;
|
|
597
|
+
if (target === input || target.closest(".nx-dropdown-chip__remove"))
|
|
598
|
+
return;
|
|
599
|
+
event.preventDefault();
|
|
600
|
+
input?.focus();
|
|
601
|
+
if (readOnly)
|
|
602
|
+
readOnlyNotice.show(input);
|
|
603
|
+
else if (!open)
|
|
604
|
+
openPanel();
|
|
605
|
+
}
|
|
606
|
+
// Focus leaving the panel for anything but the box (after dragging the list's scroll bar) closes it.
|
|
607
|
+
function handlePanelBlur(event) {
|
|
608
|
+
const next = event.relatedTarget;
|
|
609
|
+
if (next instanceof Node && (event.currentTarget.contains(next) || next === field.elementRef.current))
|
|
610
|
+
return;
|
|
611
|
+
closePanel();
|
|
612
|
+
}
|
|
613
|
+
function handlePanelKeyDown(event) {
|
|
614
|
+
if (event.target === field.elementRef.current)
|
|
615
|
+
return;
|
|
616
|
+
if (event.key === "Escape") {
|
|
617
|
+
event.preventDefault();
|
|
618
|
+
event.stopPropagation();
|
|
619
|
+
closePanel(true);
|
|
620
|
+
}
|
|
621
|
+
else if (navigate(event)) {
|
|
622
|
+
// Focus is on the list after a press on its scroll bar: keys work there too, and focus goes back to the box.
|
|
623
|
+
event.preventDefault();
|
|
624
|
+
field.elementRef.current?.focus();
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
const rowIndexOf = (target) => {
|
|
628
|
+
const row = target instanceof Element ? target.closest("[data-index]") : null;
|
|
629
|
+
return row ? Number(row.getAttribute("data-index")) : -1;
|
|
630
|
+
};
|
|
631
|
+
function handleListPointerMove(event) {
|
|
632
|
+
const index = rowIndexOf(event.target);
|
|
633
|
+
const row = rows[index];
|
|
634
|
+
if (row && index !== active && !rowDisabled(row))
|
|
635
|
+
setActive(index);
|
|
636
|
+
}
|
|
637
|
+
function handleListClick(event) {
|
|
638
|
+
const row = rows[rowIndexOf(event.target)];
|
|
639
|
+
if (row && !staleRows)
|
|
640
|
+
pick(row);
|
|
641
|
+
}
|
|
642
|
+
function handleListScroll(event) {
|
|
643
|
+
const list = event.currentTarget;
|
|
644
|
+
if (list.scrollHeight - list.scrollTop - list.clientHeight > NEAR_END)
|
|
645
|
+
return;
|
|
646
|
+
if (loadOptions)
|
|
647
|
+
loadMore();
|
|
648
|
+
else if (renderLimit < offered.length)
|
|
649
|
+
setRenderLimit(limit => limit + RENDER_STEP);
|
|
650
|
+
}
|
|
651
|
+
function retry() {
|
|
652
|
+
loadPage(server.search, server.errorPage);
|
|
653
|
+
}
|
|
654
|
+
// The panel is a manual popover under the box (above it when there is more room there), at least as wide as the box.
|
|
655
|
+
// It closes only through this component, so the state always matches the screen.
|
|
656
|
+
useLayoutEffect(() => {
|
|
657
|
+
const panel = panelRef.current;
|
|
658
|
+
const box = field.elementRef.current?.closest(".nx-field__box");
|
|
659
|
+
if (!open || !panel || !box)
|
|
660
|
+
return;
|
|
661
|
+
if (!isPopoverOpen(panel))
|
|
662
|
+
panel.showPopover();
|
|
663
|
+
let side;
|
|
664
|
+
const place = () => {
|
|
665
|
+
side = placeListPanel(panel, box.getBoundingClientRect(), { side, maxHeight: PANEL_MAX_HEIGHT });
|
|
666
|
+
};
|
|
667
|
+
place();
|
|
668
|
+
const list = listRef.current;
|
|
669
|
+
// The width the browser keeps for a scroll bar (0 where scroll bars float over the content): the list's CSS takes it
|
|
670
|
+
// off its right padding.
|
|
671
|
+
if (list)
|
|
672
|
+
panel.style.setProperty("--nx-dropdown-gutter", `${list.offsetWidth - list.clientWidth}px`);
|
|
673
|
+
// A single choice opens with its row in the middle of the list.
|
|
674
|
+
const chosen = !multiple && list?.querySelector('[role="option"][aria-selected="true"]');
|
|
675
|
+
if (list && chosen)
|
|
676
|
+
list.scrollTop = chosen.offsetTop - (list.clientHeight - chosen.offsetHeight) / 2;
|
|
677
|
+
const stopFollowing = followAnchor(place);
|
|
678
|
+
placeRef.current = place;
|
|
679
|
+
// Sizes that change outside a render (an image loading in a row, say): place again in the next frame.
|
|
680
|
+
let frame = 0;
|
|
681
|
+
const resize = new ResizeObserver(() => {
|
|
682
|
+
cancelAnimationFrame(frame);
|
|
683
|
+
frame = requestAnimationFrame(place);
|
|
684
|
+
});
|
|
685
|
+
resize.observe(panel);
|
|
686
|
+
resize.observe(box);
|
|
687
|
+
const onPointerDown = (event) => {
|
|
688
|
+
const target = event.target;
|
|
689
|
+
if (!panel.contains(target) && !box.contains(target))
|
|
690
|
+
closePanel();
|
|
691
|
+
};
|
|
692
|
+
document.addEventListener("pointerdown", onPointerDown, true);
|
|
693
|
+
return () => {
|
|
694
|
+
stopFollowing();
|
|
695
|
+
placeRef.current = null;
|
|
696
|
+
cancelAnimationFrame(frame);
|
|
697
|
+
resize.disconnect();
|
|
698
|
+
document.removeEventListener("pointerdown", onPointerDown, true);
|
|
699
|
+
if (isPopoverOpen(panel))
|
|
700
|
+
panel.hidePopover();
|
|
701
|
+
};
|
|
702
|
+
}, [open]);
|
|
703
|
+
// Rows arriving, and chips wrapping in the box after a pick, change the sizes in a render: placing again before the
|
|
704
|
+
// browser paints keeps the panel from showing for a frame over the grown box, or past the window's edge.
|
|
705
|
+
useLayoutEffect(() => {
|
|
706
|
+
placeRef.current?.();
|
|
707
|
+
});
|
|
708
|
+
// After a keyboard move, the highlighted row scrolls into view below the column headings. Letters typed while the
|
|
709
|
+
// list was closed find their option once its rows exist.
|
|
710
|
+
useLayoutEffect(() => {
|
|
711
|
+
if (!open)
|
|
712
|
+
return;
|
|
713
|
+
if (typeaheadRef.current.pending)
|
|
714
|
+
applyTypeahead();
|
|
715
|
+
const list = listRef.current;
|
|
716
|
+
if (!scrollToActiveRef.current || !list)
|
|
717
|
+
return;
|
|
718
|
+
scrollToActiveRef.current = false;
|
|
719
|
+
const row = list.querySelector(`[data-index="${activeIndex}"]`);
|
|
720
|
+
if (!row)
|
|
721
|
+
return;
|
|
722
|
+
const header = list.querySelector(".nx-dropdown-list__header");
|
|
723
|
+
const style = getComputedStyle(list);
|
|
724
|
+
const top = row.offsetTop - (header ? header.offsetHeight + 4 : parseFloat(style.paddingTop));
|
|
725
|
+
const bottom = row.offsetTop + row.offsetHeight + parseFloat(style.paddingBottom) - list.clientHeight;
|
|
726
|
+
if (list.scrollTop > top)
|
|
727
|
+
list.scrollTop = top;
|
|
728
|
+
else if (list.scrollTop < bottom)
|
|
729
|
+
list.scrollTop = bottom;
|
|
730
|
+
});
|
|
731
|
+
// A short server page leaves no scroll bar to reach the end with: load on until the list can scroll.
|
|
732
|
+
useEffect(() => {
|
|
733
|
+
const list = listRef.current;
|
|
734
|
+
if (open && list && server.status === "ready" && server.hasMore && list.scrollHeight - list.clientHeight <= NEAR_END)
|
|
735
|
+
loadMore();
|
|
736
|
+
}, [open, server]);
|
|
737
|
+
// The box
|
|
738
|
+
const mediaFor = (item) => _jsx(DropdownMedia, { item: item, label: labelOf(item), columns: columns, iconKey: iconKey });
|
|
739
|
+
// The names of values in a sentence: up to NAMES_LISTED, then how many more, so thousands of values stay one short line.
|
|
740
|
+
const namesOf = (list) => {
|
|
741
|
+
const names = list.slice(0, NAMES_LISTED).map(value => {
|
|
742
|
+
const item = findItem(optionKey(value));
|
|
743
|
+
return item ? labelOf(item) : optionKey(value);
|
|
744
|
+
});
|
|
745
|
+
return list.length > NAMES_LISTED ? `${names.join(", ")}, and ${formatCount(list.length - NAMES_LISTED)} more` : names.join(", ");
|
|
746
|
+
};
|
|
747
|
+
let boxContent;
|
|
748
|
+
if (multiple) {
|
|
749
|
+
// Past maxChips, one chip counts the rest: a long selection (Select all, say) keeps the box small.
|
|
750
|
+
const chipValues = values.length > Math.max(0, maxChips) ? values.slice(0, Math.max(0, maxChips)) : values;
|
|
751
|
+
const moreValues = values.slice(chipValues.length);
|
|
752
|
+
boxContent = (_jsxs(_Fragment, { children: [chipValues.map(value => {
|
|
753
|
+
const key = optionKey(value);
|
|
754
|
+
const item = findItem(key);
|
|
755
|
+
return (_jsx(DropdownChip, { label: item ? labelOf(item) : key, media: item ? mediaFor(item) : null, state: isPending(key, item) ? null : stateOf(key, item), pending: isPending(key, item), onRemove: editable ? () => remove(key) : undefined }, key));
|
|
756
|
+
}), moreValues.length > 0 && (_jsx("span", { className: "nx-dropdown-chip nx-dropdown-chip--more", title: namesOf(moreValues), children: chipValues.length > 0 ? `+${formatCount(moreValues.length)} more` : `${formatCount(values.length)} selected` }))] }));
|
|
757
|
+
}
|
|
758
|
+
else if (values.length > 0) {
|
|
759
|
+
const key = optionKey(values[0]);
|
|
760
|
+
const item = findItem(key);
|
|
761
|
+
const pending = isPending(key, item);
|
|
762
|
+
boxContent = (_jsxs("span", { className: "nx-dropdown__value", "aria-hidden": "true", children: [item && renderValue ? (renderValue(item)) : (_jsxs(_Fragment, { children: [item && mediaFor(item), _jsx("span", { className: "nx-dropdown__value-text", children: pending ? _jsx("span", { className: "nx-dropdown-skeleton" }) : item ? labelOf(item) : key })] })), !pending && _jsx(DropdownTag, { state: stateOf(key, item) })] }));
|
|
763
|
+
}
|
|
764
|
+
const showClear = clearable && editable && values.length > 0;
|
|
765
|
+
// The list
|
|
766
|
+
let listContent = null;
|
|
767
|
+
let footer = null;
|
|
768
|
+
if (open) {
|
|
769
|
+
const typedColumns = columns;
|
|
770
|
+
const tagColumn = typedColumns ? tagColumnOf(typedColumns, labelKey) : -1;
|
|
771
|
+
const iconSlot = !typedColumns && rows.some(row => row.item && hasIcon(readField(row.item, iconKey)));
|
|
772
|
+
const showHeader = typedColumns?.some(column => column.header);
|
|
773
|
+
const skeleton = rows.length === 0 && ((Boolean(loadOptions) && (server.status === "idle" || server.status === "loading")) || (!loadOptions && loading));
|
|
774
|
+
const failed = Boolean(loadOptions) && server.status === "error";
|
|
775
|
+
const empty = rows.length === 0 && !skeleton && !failed;
|
|
776
|
+
const typed = deferredSearch.trim();
|
|
777
|
+
listContent = (_jsxs(_Fragment, { children: [showHeader && (_jsxs("div", { className: "nx-dropdown-list__header", "aria-hidden": "true", children: [_jsx("span", {}), typedColumns.map((column, index) => (_jsx("span", { className: "nx-dropdown-list__heading", style: column.align && column.align !== "start" ? { justifySelf: column.align, textAlign: column.align } : undefined, children: column.header }, index)))] })), rows.map((row, index) => {
|
|
778
|
+
const labelField = row.item ? readField(row.item, labelKey) : undefined;
|
|
779
|
+
const description = row.item && !typedColumns ? readField(row.item, descriptionKey) : undefined;
|
|
780
|
+
const pending = isPending(row.key, row.item);
|
|
781
|
+
return (_jsx(RowGroup, { heading: index === 0 && pinnedCount > 0 && pinnedCount < rows.length ? "Selected" : undefined, separator: row.pinned === false && index === pinnedCount && pinnedCount > 0, children: _jsx(DropdownOption, { id: `${listId}-${index}`, index: index, item: row.item, label: row.item ? labelOf(row.item) : row.key, labelNode: isValidElement(labelField) ? labelField : undefined, description: typeof description === "string" || typeof description === "number" || isValidElement(description) ? description : undefined, columns: typedColumns, tagColumn: tagColumn, iconKey: iconKey, iconSlot: iconSlot, multiple: multiple, selected: selectedKeys.has(row.key), active: index === activeIndex, disabled: rowDisabled(row), pending: pending, state: row.pinned && !pending ? stateOf(row.key, row.item) : null }) }, `${row.pinned ? "p" : "o"}-${row.key}`));
|
|
782
|
+
}), skeleton &&
|
|
783
|
+
SKELETON_WIDTHS.map((width, index) => (_jsxs("div", { className: "nx-dropdown-option nx-dropdown-option--skeleton", "aria-hidden": "true", children: [_jsx("span", { className: "nx-dropdown-option__check" }), typedColumns ? (typedColumns.map((column, columnIndex) => (_jsx("span", { className: cx("nx-dropdown-skeleton", (column.type === "avatar" || column.type === "image" || column.type === "icon") && `nx-dropdown-skeleton--${column.type}`), style: column.type === "avatar" || column.type === "image" || column.type === "icon" ? undefined : { width } }, columnIndex)))) : (_jsx("span", { className: "nx-dropdown-skeleton", style: { width } }))] }, `skeleton-${index}`))), server.status === "more" && (_jsxs("div", { className: "nx-dropdown-list__status", children: [_jsx(InlineIcon, { icon: iconLoader2, className: "nx-spin" }), _jsx("span", { children: "Loading more\u2026" })] })), failed && (_jsxs("div", { className: "nx-dropdown-list__status nx-dropdown-list__status--error", role: "alert", children: [_jsx(InlineIcon, { icon: iconAlertCircle }), _jsx("span", { children: server.errorPage > 1 ? "Could not load more options." : "Could not load the options." }), _jsx(Button, { size: "sm", variant: "secondary", onClick: retry, children: "Retry" })] })), empty && _jsx("div", { className: "nx-dropdown-list__status", children: typed ? `No matches for "${typed}"` : emptyMessage })] }));
|
|
784
|
+
const shownCount = loadOptions && server.total !== undefined && server.status !== "loading" ? `${formatCount(server.items.length)} of ${formatCount(server.total)} shown` : null;
|
|
785
|
+
const counts = [];
|
|
786
|
+
if (multiple)
|
|
787
|
+
counts.push(max !== undefined ? `${values.length} of ${max} selected` : `${values.length} selected`);
|
|
788
|
+
// With Select all, its label says how many rows a server list has loaded ("Select 30 shown"), and the total moves to its tooltip.
|
|
789
|
+
if (shownCount && !bulk)
|
|
790
|
+
counts.push(shownCount);
|
|
791
|
+
const count = (_jsx("span", { className: "nx-dropdown-panel__count", "aria-live": "polite", children: counts.join(" · ") }));
|
|
792
|
+
const done = multiple && (_jsx(Button, { size: "sm", variant: "primary", onClick: () => closePanel(true), children: "Done" }));
|
|
793
|
+
if (bulk) {
|
|
794
|
+
// One footer: Select all and Clear on the left, the count and Done on the right. Rows that are about to change
|
|
795
|
+
// (a new search loading, placeholders) take no bulk action.
|
|
796
|
+
const settled = !skeleton && !staleRows && !(loading && !loadOptions);
|
|
797
|
+
const overMax = max !== undefined && values.length + bulk.addable.length > max;
|
|
798
|
+
const partial = Boolean(loadOptions) && server.hasMore;
|
|
799
|
+
const selectNote = [overMax && bulk.addable.length > 0 ? `Up to ${max} can be selected.` : "", partial && shownCount ? `${shownCount}: scroll the list to load more.` : ""].filter(Boolean).join(" ");
|
|
800
|
+
footer = (_jsxs("div", { className: "nx-dropdown-panel__foot nx-dropdown-panel__foot--actions", children: [_jsxs("div", { className: "nx-dropdown-panel__actions", children: [_jsx(Button, { size: "sm", variant: "ghost", hoverTone: "primary", disabled: !settled || bulk.addable.length === 0 || overMax, title: selectNote || undefined, onClick: selectShown, children: partial ? `Select ${formatCount(rows.length)} shown` : "Select all" }), _jsx(Button, { size: "sm", variant: "ghost", hoverTone: "primary", disabled: !settled || bulk.chosen.length === 0, onClick: clearShown, children: "Clear" })] }), _jsxs("div", { className: "nx-dropdown-panel__end", children: [count, done] })] }));
|
|
801
|
+
}
|
|
802
|
+
else if (multiple || counts.length > 0) {
|
|
803
|
+
footer = (_jsxs("div", { className: "nx-dropdown-panel__foot", children: [count, done] }));
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
// The check column, the columns, and an empty last track whose gap keeps text off the row's end.
|
|
807
|
+
const gridColumns = columns ? `auto ${columns.map(column => columnTrack(column)).join(" ")} 0px` : undefined;
|
|
808
|
+
const panelStyle = panelWidth === undefined ? undefined : { width: typeof panelWidth === "number" ? `${panelWidth}px` : panelWidth };
|
|
809
|
+
return (_jsxs(_Fragment, { children: [_jsxs(Field, { controlId: field.controlId, messageId: field.messageId, label: label, required: required, design: design, size: size, hint: hint, error: field.error, icon: icon, actions: _jsxs(_Fragment, { children: [busy && (_jsx("span", { className: "nx-dropdown__spinner", title: "Loading", "aria-hidden": "true", children: _jsx(InlineIcon, { icon: iconLoader2, className: "nx-spin" }) })), showClear && (_jsx("button", { type: "button", className: "nx-field__action nx-dropdown__clear", tabIndex: -1, "aria-label": "Clear", title: "Clear", onMouseDown: event => event.preventDefault(), onClick: clear, children: _jsx(InlineIcon, { icon: iconX }) })), readOnlyNotice.lock, !readOnly && (_jsx("button", { type: "button", className: "nx-field__action nx-dropdown__toggle", tabIndex: -1, "aria-label": open ? "Close the list" : "Open the list", "aria-expanded": open, "aria-controls": open ? listId : undefined, disabled: disabled, onMouseDown: event => event.preventDefault(), onClick: () => (open ? closePanel() : openPanel()), children: _jsx(InlineIcon, { icon: iconChevronDown }) }))] }), disabled: disabled, readOnly: readOnly, raiseLabel: values.length > 0 || Boolean(placeholder), className: cx("nx-field--dropdown", className), style: style, children: [_jsxs("div", { className: cx("nx-dropdown__control", multiple ? "nx-dropdown__control--multiple" : "nx-dropdown__control--single"), "data-searchable": searchable || undefined, "data-has-value": values.length > 0 || undefined, onMouseDown: handleControlMouseDown, children: [boxContent, _jsx("input", { autoComplete: "off", spellCheck: false, ...inputProps, ...field.controlProps, type: "text", role: "combobox", "aria-expanded": open, "aria-haspopup": "listbox", "aria-controls": open ? listId : undefined, "aria-activedescendant": activeIndex >= 0 ? `${listId}-${activeIndex}` : undefined, "aria-autocomplete": searchable ? "list" : "none", inputMode: searchable ? inputProps.inputMode : "none", name: undefined, form: form, className: cx("nx-field__control", "nx-dropdown__input", inputClassName), placeholder: values.length > 0 ? " " : placeholder || " ", disabled: disabled, readOnly: readOnly, onBlur: handleBlur, onFocus: onFocus, onKeyDown: handleKeyDown, onClick: handleClick }), _jsx("span", { id: selectionId, className: "nx-visually-hidden", children: values.length > 0 ? `Selected: ${namesOf(values)}` : "" })] }), name &&
|
|
810
|
+
(multiple ? (values.map(value => _jsx("input", { type: "hidden", name: name, form: form, disabled: disabled, value: optionKey(value) }, optionKey(value)))) : (_jsx("input", { type: "hidden", name: name, form: form, disabled: disabled, value: optionKey(values[0]) })))] }), readOnlyNotice.notice, !readOnly && (_jsx("div", { ref: panelRef, popover: "manual", className: cx("nx-popover", "nx-dropdown-panel", `nx-dropdown-panel--${size}`), style: panelStyle, onKeyDown: handlePanelKeyDown, onBlur: handlePanelBlur,
|
|
811
|
+
// The box keeps focus while rows and buttons are pressed. A press on the list itself is left alone, so its
|
|
812
|
+
// scroll bar can be dragged.
|
|
813
|
+
onMouseDown: event => {
|
|
814
|
+
if (event.target !== listRef.current)
|
|
815
|
+
event.preventDefault();
|
|
816
|
+
}, children: open && (_jsxs(_Fragment, { children: [_jsx("div", { ref: listRef, id: listId, role: "listbox", "aria-label": messageName === DEFAULT_FIELD_NAME ? "Options" : messageName, "aria-multiselectable": multiple || undefined, "aria-busy": busy || undefined, tabIndex: -1, className: cx("nx-dropdown-list", columns && "nx-dropdown-list--columns"), style: gridColumns ? { gridTemplateColumns: gridColumns } : undefined, "data-stale": staleRows || undefined, onPointerMove: handleListPointerMove, onClick: handleListClick, onScroll: handleListScroll, children: listContent }), footer] })) }))] }));
|
|
817
|
+
}
|
|
818
|
+
/** A row, after the "Selected" heading when it opens the list, or after a line when it follows the selected rows. */
|
|
819
|
+
function RowGroup({ heading, separator, children }) {
|
|
820
|
+
return (_jsxs(_Fragment, { children: [heading && (_jsx("div", { className: "nx-dropdown-list__group", role: "presentation", children: heading })), separator && _jsx("div", { className: "nx-dropdown-list__separator", role: "separator" }), children] }));
|
|
821
|
+
}
|
|
822
|
+
/** The column that carries a row's tag: the label's column, or else the last text column, or else the last. */
|
|
823
|
+
function tagColumnOf(columns, labelKey) {
|
|
824
|
+
const labelColumn = columns.findIndex(column => column.key === labelKey);
|
|
825
|
+
if (labelColumn >= 0)
|
|
826
|
+
return labelColumn;
|
|
827
|
+
for (let index = columns.length - 1; index >= 0; index--) {
|
|
828
|
+
const type = columns[index].type ?? "text";
|
|
829
|
+
if (type === "text" || type === "muted" || type === "code")
|
|
830
|
+
return index;
|
|
831
|
+
}
|
|
832
|
+
return columns.length - 1;
|
|
833
|
+
}
|
|
834
|
+
const formatCount = (count) => count.toLocaleString("en-US");
|
|
835
|
+
/** Items of a later page that earlier pages do not hold already, after them. Server data can shift between requests. */
|
|
836
|
+
function appendNew(items, page, valueKey) {
|
|
837
|
+
const keys = new Set(items.map(item => optionKey(itemValue(item, valueKey))));
|
|
838
|
+
return [...items, ...page.filter(item => !keys.has(optionKey(itemValue(item, valueKey))))];
|
|
839
|
+
}
|