@eifi1/ui-kit 0.21.0 → 0.23.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/README.md +42 -25
- package/dist/chart.d.ts +19 -14
- package/dist/components/account-chips.d.ts +10 -3
- package/dist/components/account-chips.js +5 -2
- package/dist/components/account-chips.js.map +1 -1
- package/dist/components/account-settings.d.ts +33 -2
- package/dist/components/account-settings.js +43 -21
- package/dist/components/account-settings.js.map +1 -1
- package/dist/components/amount-input.d.ts +53 -14
- package/dist/components/amount-input.js +17 -28
- package/dist/components/amount-input.js.map +1 -1
- package/dist/components/autocomplete.d.ts +29 -0
- package/dist/components/autocomplete.js +134 -104
- package/dist/components/autocomplete.js.map +1 -1
- package/dist/components/button-group.d.ts +19 -14
- package/dist/components/calculator.d.ts +19 -14
- package/dist/components/checkbox-group.d.ts +104 -0
- package/dist/components/checkbox-group.js +111 -0
- package/dist/components/checkbox-group.js.map +1 -0
- package/dist/components/checkbox.d.ts +19 -0
- package/dist/components/checkbox.js +30 -7
- package/dist/components/checkbox.js.map +1 -1
- package/dist/components/chip.d.ts +18 -0
- package/dist/components/chip.js +47 -10
- package/dist/components/chip.js.map +1 -1
- package/dist/components/column-mapper.d.ts +182 -0
- package/dist/components/column-mapper.js +375 -0
- package/dist/components/column-mapper.js.map +1 -0
- package/dist/components/combobox-core.d.ts +39 -2
- package/dist/components/combobox-core.js +29 -6
- package/dist/components/combobox-core.js.map +1 -1
- package/dist/components/combobox.d.ts +91 -2
- package/dist/components/combobox.js +454 -308
- package/dist/components/combobox.js.map +1 -1
- package/dist/components/confirm-dialog.d.ts +69 -161
- package/dist/components/confirm-dialog.js +7 -8
- package/dist/components/confirm-dialog.js.map +1 -1
- package/dist/components/copy-button.d.ts +16 -11
- package/dist/components/country-select.d.ts +167 -0
- package/dist/components/country-select.js +293 -0
- package/dist/components/country-select.js.map +1 -0
- package/dist/components/currency-select.d.ts +17 -1
- package/dist/components/currency-select.js +56 -23
- package/dist/components/currency-select.js.map +1 -1
- package/dist/components/danger-confirm.d.ts +69 -166
- package/dist/components/danger-confirm.js +144 -50
- package/dist/components/danger-confirm.js.map +1 -1
- package/dist/components/data-table-cells.d.ts +1 -2
- package/dist/components/data-table-filter-popover.d.ts +1 -2
- package/dist/components/data-table-filters.d.ts +1 -2
- package/dist/components/data-table-labels.d.ts +4 -97
- package/dist/components/data-table-labels.js +9 -1
- package/dist/components/data-table-labels.js.map +1 -1
- package/dist/components/data-table-pagination.d.ts +3 -1
- package/dist/components/data-table.d.ts +1 -2
- package/dist/components/data-table.js +98 -3
- package/dist/components/data-table.js.map +1 -1
- package/dist/components/date-picker.d.ts +66 -5
- package/dist/components/date-picker.js +378 -66
- package/dist/components/date-picker.js.map +1 -1
- package/dist/components/entity-combobox.d.ts +42 -1
- package/dist/components/entity-combobox.js +154 -113
- package/dist/components/entity-combobox.js.map +1 -1
- package/dist/components/facing-pair.d.ts +19 -14
- package/dist/components/field-parts.d.ts +224 -0
- package/dist/components/field-parts.js +156 -0
- package/dist/components/field-parts.js.map +1 -0
- package/dist/components/field-strip.d.ts +105 -0
- package/dist/components/field-strip.js +46 -0
- package/dist/components/field-strip.js.map +1 -0
- package/dist/components/file-button.d.ts +19 -14
- package/dist/components/file-button.js +13 -3
- package/dist/components/file-button.js.map +1 -1
- package/dist/components/file-dropzone.d.ts +41 -15
- package/dist/components/file-dropzone.js +57 -34
- package/dist/components/file-dropzone.js.map +1 -1
- package/dist/components/form-actions.d.ts +18 -13
- package/dist/components/form-actions.js +69 -5
- package/dist/components/form-actions.js.map +1 -1
- package/dist/components/iban-input.d.ts +69 -0
- package/dist/components/iban-input.js +125 -0
- package/dist/components/iban-input.js.map +1 -0
- package/dist/components/icon-picker.d.ts +21 -2
- package/dist/components/icon-picker.js +19 -4
- package/dist/components/icon-picker.js.map +1 -1
- package/dist/components/inline-edit-field.d.ts +167 -0
- package/dist/components/inline-edit-field.js +293 -0
- package/dist/components/inline-edit-field.js.map +1 -0
- package/dist/components/language-select.d.ts +130 -0
- package/dist/components/language-select.js +18 -0
- package/dist/components/language-select.js.map +1 -0
- package/dist/components/line-items.d.ts +25 -3
- package/dist/components/line-items.js +55 -46
- package/dist/components/line-items.js.map +1 -1
- package/dist/components/list.d.ts +25 -0
- package/dist/components/list.js +16 -5
- package/dist/components/list.js.map +1 -1
- package/dist/components/money-field.d.ts +32 -14
- package/dist/components/money-field.js.map +1 -1
- package/dist/components/month-picker.d.ts +52 -4
- package/dist/components/month-picker.js +291 -86
- package/dist/components/month-picker.js.map +1 -1
- package/dist/components/multi-entity-combobox.d.ts +26 -1
- package/dist/components/multi-entity-combobox.js +143 -102
- package/dist/components/multi-entity-combobox.js.map +1 -1
- package/dist/components/multi-select.d.ts +8 -1
- package/dist/components/multi-select.js +9 -4
- package/dist/components/multi-select.js.map +1 -1
- package/dist/components/number-field.d.ts +34 -19
- package/dist/components/number-field.js +6 -20
- package/dist/components/number-field.js.map +1 -1
- package/dist/components/number-input.d.ts +79 -19
- package/dist/components/number-input.js +90 -38
- package/dist/components/number-input.js.map +1 -1
- package/dist/components/numpad-sheet.d.ts +19 -14
- package/dist/components/one-time-code-input.d.ts +100 -0
- package/dist/components/one-time-code-input.js +137 -0
- package/dist/components/one-time-code-input.js.map +1 -0
- package/dist/components/phone-input.d.ts +69 -0
- package/dist/components/phone-input.js +171 -0
- package/dist/components/phone-input.js.map +1 -0
- package/dist/components/reauth-dialog.d.ts +2 -2
- package/dist/components/reauth-dialog.js +6 -8
- package/dist/components/reauth-dialog.js.map +1 -1
- package/dist/components/series-chart.d.ts +19 -14
- package/dist/components/settings-fields.d.ts +19 -14
- package/dist/components/share-card.d.ts +16 -11
- package/dist/components/sign-chip.d.ts +66 -0
- package/dist/components/sign-chip.js +39 -0
- package/dist/components/sign-chip.js.map +1 -0
- package/dist/components/swatch-picker.d.ts +43 -2
- package/dist/components/swatch-picker.js +80 -32
- package/dist/components/swatch-picker.js.map +1 -1
- package/dist/components/switch.d.ts +19 -0
- package/dist/components/switch.js +24 -6
- package/dist/components/switch.js.map +1 -1
- package/dist/components/text-link.d.ts +18 -13
- package/dist/components/tile-radio.d.ts +88 -13
- package/dist/components/tile-radio.js +28 -11
- package/dist/components/tile-radio.js.map +1 -1
- package/dist/components/time-input.d.ts +24 -15
- package/dist/components/time-input.js.map +1 -1
- package/dist/components/toggle-group.d.ts +47 -1
- package/dist/components/toggle-group.js +110 -69
- package/dist/components/toggle-group.js.map +1 -1
- package/dist/components/trigger-aria.d.ts +3 -0
- package/dist/components/trigger-aria.js +3 -1
- package/dist/components/trigger-aria.js.map +1 -1
- package/dist/components/ui.d.ts +16 -11
- package/dist/components/ui.js +284 -88
- package/dist/components/ui.js.map +1 -1
- package/dist/components/use-table-state.d.ts +1 -2
- package/dist/{data-table-3mi2R3so.d.ts → data-table-labels-B7OdnM0S.d.ts} +140 -3
- package/dist/data-table.d.ts +1 -2
- package/dist/data-table.js +2 -1
- package/dist/data-table.js.map +1 -1
- package/dist/feedback/feedback-attachment.d.ts +1 -1
- package/dist/feedback/feedback-attachment.js +348 -41
- package/dist/feedback/feedback-attachment.js.map +1 -1
- package/dist/feedback/feedback-dialog.d.ts +1 -1
- package/dist/feedback/feedback-dialog.js.map +1 -1
- package/dist/feedback/feedback-inbox.d.ts +3 -2
- package/dist/feedback/feedback-inbox.js.map +1 -1
- package/dist/feedback/feedback-thread.d.ts +68 -253
- package/dist/feedback/feedback-thread.js +46 -10
- package/dist/feedback/feedback-thread.js.map +1 -1
- package/dist/{kit-labels-D1J2_jyD.d.ts → feedback-BxeQVzwq.d.ts} +1206 -20
- package/dist/{feedback-attachment-WFiXJ8We.d.ts → feedback-attachment-fGAzZPf0.d.ts} +178 -11
- package/dist/feedback.d.ts +66 -2
- package/dist/hooks/use-file-drop.d.ts +19 -14
- package/dist/i18n/defaults.d.ts +19 -14
- package/dist/i18n/defaults.js +19 -2
- package/dist/i18n/defaults.js.map +1 -1
- package/dist/i18n/german.d.ts +19 -14
- package/dist/i18n/german.js +84 -5
- package/dist/i18n/german.js.map +1 -1
- package/dist/i18n/kit-labels.d.ts +15 -10
- package/dist/i18n/kit-labels.js +8 -2
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/languages.d.ts +19 -14
- package/dist/i18n/locales/de-CH.d.ts +19 -14
- package/dist/i18n/locales/en.d.ts +19 -14
- package/dist/i18n/locales/en.js +16 -0
- package/dist/i18n/locales/en.js.map +1 -1
- package/dist/i18n/locales/es.d.ts +19 -14
- package/dist/i18n/locales/es.js +83 -5
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.d.ts +19 -14
- package/dist/i18n/locales/fr.js +82 -5
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.d.ts +19 -14
- package/dist/i18n/locales/hu.js +85 -5
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.d.ts +19 -14
- package/dist/i18n/locales/it.js +84 -5
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.d.ts +19 -14
- package/dist/i18n/locales/zh.js +80 -5
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/i18n/review.d.ts +19 -14
- package/dist/i18n/review.js +27 -1
- package/dist/i18n/review.js.map +1 -1
- package/dist/index.d.ts +21 -10
- package/dist/index.js +73 -2
- package/dist/index.js.map +1 -1
- package/dist/lib/column-mapping.d.ts +81 -0
- package/dist/lib/column-mapping.js +108 -0
- package/dist/lib/column-mapping.js.map +1 -0
- package/dist/lib/countries.d.ts +43 -0
- package/dist/lib/countries.js +39 -0
- package/dist/lib/countries.js.map +1 -0
- package/dist/lib/decimal-marks.d.ts +46 -0
- package/dist/lib/decimal-marks.js +40 -0
- package/dist/lib/decimal-marks.js.map +1 -0
- package/dist/lib/iban.d.ts +98 -0
- package/dist/lib/iban.js +154 -0
- package/dist/lib/iban.js.map +1 -0
- package/dist/lib/isin.d.ts +30 -0
- package/dist/lib/isin.js +36 -0
- package/dist/lib/isin.js.map +1 -0
- package/dist/lib/phone.d.ts +69 -0
- package/dist/lib/phone.js +121 -0
- package/dist/lib/phone.js.map +1 -0
- package/dist/lib/table-text.d.ts +109 -1
- package/dist/lib/table-text.js +122 -1
- package/dist/lib/table-text.js.map +1 -1
- package/dist/rhf/fields.d.ts +233 -18
- package/dist/rhf/fields.js +456 -3
- package/dist/rhf/fields.js.map +1 -1
- package/dist/rhf/form.d.ts +19 -14
- package/dist/rhf.d.ts +22 -15
- package/dist/rhf.js.map +1 -1
- package/dist/shell/app-shell.d.ts +18 -13
- package/dist/shell/top-bar-brand.d.ts +19 -14
- package/dist/shell/topbar-action-menu.d.ts +36 -3
- package/dist/shell/topbar-action-menu.js +74 -33
- package/dist/shell/topbar-action-menu.js.map +1 -1
- package/dist/shell.d.ts +18 -13
- package/dist/table-text.d.ts +1 -1
- package/dist/tour/tour.js +20 -24
- package/dist/tour/tour.js.map +1 -1
- package/dist/wizard/stepper-nav.d.ts +49 -16
- package/dist/wizard/stepper-nav.js +4 -0
- package/dist/wizard/stepper-nav.js.map +1 -1
- package/dist/wizard.d.ts +19 -14
- package/package.json +1 -1
- package/src/components/account-chips.tsx +17 -3
- package/src/components/account-settings.tsx +92 -26
- package/src/components/amount-input.tsx +68 -56
- package/src/components/autocomplete.tsx +198 -114
- package/src/components/checkbox-group.tsx +229 -0
- package/src/components/checkbox.tsx +72 -8
- package/src/components/chip.tsx +80 -10
- package/src/components/column-mapper.tsx +666 -0
- package/src/components/combobox-core.tsx +82 -9
- package/src/components/combobox.tsx +661 -340
- package/src/components/confirm-dialog.tsx +12 -8
- package/src/components/country-select.tsx +556 -0
- package/src/components/currency-select.tsx +109 -33
- package/src/components/danger-confirm.tsx +429 -82
- package/src/components/data-table-labels.ts +16 -1
- package/src/components/data-table.tsx +175 -6
- package/src/components/date-picker.tsx +595 -71
- package/src/components/entity-combobox.tsx +235 -124
- package/src/components/field-parts.tsx +403 -0
- package/src/components/field-strip.tsx +149 -0
- package/src/components/file-button.tsx +36 -3
- package/src/components/file-dropzone.tsx +106 -38
- package/src/components/form-actions.tsx +147 -6
- package/src/components/iban-input.tsx +231 -0
- package/src/components/icon-picker.tsx +47 -6
- package/src/components/inline-edit-field.tsx +533 -0
- package/src/components/language-select.tsx +78 -0
- package/src/components/line-items.tsx +35 -2
- package/src/components/list.tsx +55 -6
- package/src/components/money-field.tsx +13 -0
- package/src/components/month-picker.tsx +400 -63
- package/src/components/multi-entity-combobox.tsx +207 -120
- package/src/components/multi-select.tsx +80 -58
- package/src/components/number-field.tsx +28 -46
- package/src/components/number-input.tsx +210 -43
- package/src/components/one-time-code-input.tsx +288 -0
- package/src/components/phone-input.tsx +299 -0
- package/src/components/reauth-dialog.tsx +17 -18
- package/src/components/sign-chip.tsx +103 -0
- package/src/components/swatch-picker.tsx +143 -31
- package/src/components/switch.tsx +58 -7
- package/src/components/tile-radio.tsx +145 -29
- package/src/components/time-input.tsx +8 -1
- package/src/components/toggle-group.tsx +206 -73
- package/src/components/trigger-aria.ts +5 -0
- package/src/components/ui.tsx +479 -138
- package/src/data-table.ts +2 -1
- package/src/feedback/feedback-attachment.tsx +597 -46
- package/src/feedback/feedback-dialog.tsx +9 -3
- package/src/feedback/feedback-inbox.tsx +3 -2
- package/src/feedback/feedback-thread.tsx +113 -9
- package/src/i18n/defaults.ts +18 -1
- package/src/i18n/german.ts +89 -0
- package/src/i18n/kit-labels.tsx +82 -2
- package/src/i18n/locales/en.ts +36 -4
- package/src/i18n/locales/es.ts +87 -0
- package/src/i18n/locales/fr.ts +87 -0
- package/src/i18n/locales/hu.ts +83 -0
- package/src/i18n/locales/it.ts +88 -0
- package/src/i18n/locales/zh.ts +76 -0
- package/src/i18n/review.ts +26 -0
- package/src/index.ts +71 -3
- package/src/lib/column-mapping.ts +234 -0
- package/src/lib/countries.ts +91 -0
- package/src/lib/decimal-marks.ts +99 -0
- package/src/lib/iban.ts +171 -0
- package/src/lib/isin.ts +62 -0
- package/src/lib/phone.ts +211 -0
- package/src/lib/table-text.ts +271 -0
- package/src/rhf/fields.tsx +860 -9
- package/src/rhf.ts +3 -1
- package/src/shell/topbar-action-menu.tsx +134 -38
- package/src/tour/tour.tsx +66 -28
- package/src/wizard/stepper-nav.tsx +35 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Fragment, useId, useMemo, useRef, useState } from "react";
|
|
2
2
|
import type { ComponentPropsWithoutRef, ReactNode, RefObject } from "react";
|
|
3
3
|
import { createPortal } from "react-dom";
|
|
4
|
-
import { ChevronDown, X } from "lucide-react";
|
|
4
|
+
import { ChevronDown, Plus, X } from "lucide-react";
|
|
5
5
|
import { FIELD_BASE, FIELD_FLOATING_PAD, FIELD_INVALID, PHONE_QUERY } from "./ui";
|
|
6
6
|
import { cn } from "../lib/cn";
|
|
7
7
|
import { useDropdown } from "./dropdown";
|
|
@@ -12,7 +12,9 @@ import { PickerSheet, SHEET_ROW_CLASS } from "./picker-sheet";
|
|
|
12
12
|
import {
|
|
13
13
|
ComboboxFieldLabel,
|
|
14
14
|
DISABLED_ROW_CLASS,
|
|
15
|
+
endHintRowProps,
|
|
15
16
|
isOptionEnabled,
|
|
17
|
+
offersCreate,
|
|
16
18
|
stepEnabled,
|
|
17
19
|
SUGGESTION_LIST_CLASS,
|
|
18
20
|
suggestionRowClass,
|
|
@@ -22,6 +24,17 @@ import {
|
|
|
22
24
|
type ComboOption,
|
|
23
25
|
} from "./combobox-core";
|
|
24
26
|
import { DEFAULT_COMBOBOX_LABELS, useKitLabels } from "../i18n/kit-labels";
|
|
27
|
+
import {
|
|
28
|
+
EndHintRow,
|
|
29
|
+
FieldCaption,
|
|
30
|
+
LABEL_IN_ROW,
|
|
31
|
+
LockedReason,
|
|
32
|
+
StaticLabelRow,
|
|
33
|
+
useFieldHint,
|
|
34
|
+
useLockReason,
|
|
35
|
+
} from "./field-parts";
|
|
36
|
+
import { hasMessage, mergeDescribedBy } from "./choice-parts";
|
|
37
|
+
import { useCommitReason } from "./write-lock";
|
|
25
38
|
|
|
26
39
|
// One look for both combobox flavors below — the suggestion list and its rows
|
|
27
40
|
// must stay pixel-identical between the free-text and the id-keyed variant (and
|
|
@@ -123,6 +136,27 @@ function SuggestionList({
|
|
|
123
136
|
|
|
124
137
|
const rowClass = suggestionRowClass;
|
|
125
138
|
|
|
139
|
+
/**
|
|
140
|
+
* The write lock on the two TYPED fields below — {@link Button}'s `disabledReason`
|
|
141
|
+
* path, for an input (keksdose G2).
|
|
142
|
+
*
|
|
143
|
+
* `readOnly` rather than `disabled`, as everywhere the kit locks a field: still focusable,
|
|
144
|
+
* so the reason in the Tooltip reaches a keyboard; refusing keys, so nothing typed can
|
|
145
|
+
* become a value; and FIELD_BASE paints a `[readonly]` field in the settled grey a
|
|
146
|
+
* disabled one wears. No list opens, by focus, click, chevron or arrow — and Enter is
|
|
147
|
+
* swallowed, so a locked field does not submit the form around it either (Select's
|
|
148
|
+
* lock, `LOCKED_SELECT_KEYS`). Every other key is left alone: the arrows and Home/End
|
|
149
|
+
* still move the caret through a long value someone is trying to read.
|
|
150
|
+
*/
|
|
151
|
+
function lockedKeyDown(e: { key: string; preventDefault: () => void }) {
|
|
152
|
+
if (e.key === "Enter") e.preventDefault();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** {@link InlineEntityCombobox}'s create row over the shared row look: in the brand
|
|
156
|
+
* colour {@link Combobox}'s own create row wears, with a leading "+" as the panel
|
|
157
|
+
* pickers' has — an action at the foot of the list, not one more record. */
|
|
158
|
+
const CREATE_ROW_CLASS = "flex items-center gap-2 font-medium text-[var(--brand)]";
|
|
159
|
+
|
|
126
160
|
/**
|
|
127
161
|
* Four of the div's own attributes are omitted because this component already owns
|
|
128
162
|
* the name, with a different meaning: `id` is the INPUT's (a caller labels or
|
|
@@ -149,10 +183,31 @@ export interface ComboboxProps
|
|
|
149
183
|
* field, on the `<input>`'s `aria-describedby`, and implies `invalid` (lenkbank's
|
|
150
184
|
* scope fields, which moved here off `<Input list>` + `<datalist>`). */
|
|
151
185
|
error?: ReactNode;
|
|
186
|
+
/**
|
|
187
|
+
* Standing advice for the field — {@link Input}'s `hint`, so the combobox family
|
|
188
|
+
* reads like every other field (keksdose K4: the family had `error` and no `hint`, so
|
|
189
|
+
* the app's pickers carried their captions outside the component, attached to
|
|
190
|
+
* nothing). Plain TEXT is a caption under the field on the input's
|
|
191
|
+
* `aria-describedby`, before any error; a {@link FieldHint} "?" rides the label line,
|
|
192
|
+
* or with no label sits at the field's end edge, outside the box.
|
|
193
|
+
*/
|
|
194
|
+
hint?: ReactNode;
|
|
152
195
|
/** As `<input disabled>`: no focus, no list, the field's settled look. The chevron
|
|
153
196
|
* stops toggling with it — it is a mouse target the input's own `disabled` does
|
|
154
197
|
* not reach. */
|
|
155
198
|
disabled?: boolean;
|
|
199
|
+
/**
|
|
200
|
+
* Why the value cannot be changed — {@link Button}'s `disabledReason`, for a field
|
|
201
|
+
* that SAVES itself (an inline editor whose blur writes). The input stays focusable
|
|
202
|
+
* and `aria-disabled`, is `readOnly`, opens no list and calls neither `onChange` nor
|
|
203
|
+
* `onSubmit`; the reason is in the kit {@link Tooltip} and on its `aria-describedby`.
|
|
204
|
+
* Wins over `disabled`. The family's lock (keksdose G2) — see {@link InlineEntityCombobox}.
|
|
205
|
+
*/
|
|
206
|
+
disabledReason?: ReactNode;
|
|
207
|
+
/** This field COMMITS. Under a locked {@link WriteLockProvider} it is locked the
|
|
208
|
+
* `disabledReason` way with the lock's reason; inside a form with its own Save, leave
|
|
209
|
+
* it off and put `commit` on the Save. No provider, or an unlocked one: no effect. */
|
|
210
|
+
commit?: boolean;
|
|
156
211
|
/** Heading an option belongs under. Supplying it makes this list read exactly
|
|
157
212
|
* like {@link InlineEntityCombobox}'s — one heading per group with its rows
|
|
158
213
|
* indented beneath — instead of a flat list (feedback #136 rework: the payee
|
|
@@ -217,39 +272,44 @@ export interface ComboboxProps
|
|
|
217
272
|
* visually and behaviourally consistent with the other dropdowns (feedback
|
|
218
273
|
* #230 — the payee field looked/behaved differently from every other select).
|
|
219
274
|
*/
|
|
220
|
-
export function Combobox({
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
275
|
+
export function Combobox(props: ComboboxProps) {
|
|
276
|
+
const {
|
|
277
|
+
value,
|
|
278
|
+
onChange,
|
|
279
|
+
options,
|
|
280
|
+
label,
|
|
281
|
+
id,
|
|
282
|
+
placeholder,
|
|
283
|
+
className,
|
|
284
|
+
groupBy,
|
|
285
|
+
maxSuggestions,
|
|
286
|
+
searchPlaceholder,
|
|
287
|
+
closeLabel,
|
|
288
|
+
createLabel,
|
|
289
|
+
invalid,
|
|
290
|
+
error,
|
|
291
|
+
hint,
|
|
292
|
+
disabled,
|
|
293
|
+
disabledReason,
|
|
294
|
+
commit,
|
|
295
|
+
optionAdornment,
|
|
296
|
+
autoFocus,
|
|
297
|
+
onBlur,
|
|
298
|
+
onSubmit,
|
|
299
|
+
"aria-label": ariaLabel,
|
|
300
|
+
// Off `rest` and onto the <input>, which is the combobox a reader meets: `Field`'s
|
|
301
|
+
// render-prop spreads `{ id, aria-describedby, aria-invalid, aria-required }`, and
|
|
302
|
+
// on the wrapper div the hint and required state described nothing.
|
|
303
|
+
"aria-describedby": ariaDescribedBy,
|
|
304
|
+
"aria-invalid": ariaInvalid,
|
|
305
|
+
"aria-required": ariaRequired,
|
|
306
|
+
...rest
|
|
307
|
+
} = props;
|
|
308
|
+
const hintParts = useFieldHint(hint, ariaDescribedBy);
|
|
249
309
|
const field = useComboboxFieldError(
|
|
250
310
|
error,
|
|
251
311
|
invalid || ariaInvalid === true || ariaInvalid === "true",
|
|
252
|
-
|
|
312
|
+
hintParts.describedBy,
|
|
253
313
|
);
|
|
254
314
|
const generated = useId();
|
|
255
315
|
const fieldId = id ?? generated;
|
|
@@ -264,6 +324,12 @@ export function Combobox({
|
|
|
264
324
|
const { open, setOpen, wrapperRef, panelRef } = useDropdown({ backCloses: !isPhone });
|
|
265
325
|
const primaryOnly = usePrimaryPressOnly();
|
|
266
326
|
const [active, setActive] = useState(-1);
|
|
327
|
+
// The lock (see `lockedKeyDown`): a lock arriving while the list is up closes it,
|
|
328
|
+
// adjusted while rendering so no frame offers a row that can no longer be taken.
|
|
329
|
+
const lock = useLockReason(commit, disabledReason);
|
|
330
|
+
const locked = lock.locked;
|
|
331
|
+
const inert = locked || Boolean(disabled);
|
|
332
|
+
if (locked && open) setOpen(false);
|
|
267
333
|
// A phone opens the list as a full-screen sheet with its own input, the way a
|
|
268
334
|
// native <select> does — live #200: "Paid as full screen dialog with input.
|
|
269
335
|
// Similar to the account select that already appears as full screen." The
|
|
@@ -349,7 +415,8 @@ export function Combobox({
|
|
|
349
415
|
return heads;
|
|
350
416
|
}, [matches, groupBy]);
|
|
351
417
|
|
|
352
|
-
const
|
|
418
|
+
const take = (v: string) => {
|
|
419
|
+
if (locked) return;
|
|
353
420
|
onChange(v);
|
|
354
421
|
setOpen(false);
|
|
355
422
|
setActive(-1);
|
|
@@ -369,6 +436,17 @@ export function Combobox({
|
|
|
369
436
|
const activeId = !isPhone && active >= 0 && active < matches.length ? optionId(active) : undefined;
|
|
370
437
|
useActiveOptionScroll(activeId);
|
|
371
438
|
|
|
439
|
+
// While locked the field sits in the reason's Tooltip — in a fragment with a hidden
|
|
440
|
+
// copy of the reason, Button's anatomy; `block` so a full-width field stays full width.
|
|
441
|
+
const withLock = (box: ReactNode) =>
|
|
442
|
+
locked ? (
|
|
443
|
+
<LockedReason lock={lock} className="block">
|
|
444
|
+
{box}
|
|
445
|
+
</LockedReason>
|
|
446
|
+
) : (
|
|
447
|
+
box
|
|
448
|
+
);
|
|
449
|
+
|
|
372
450
|
const typed = value.trim();
|
|
373
451
|
const createRow =
|
|
374
452
|
createLabel && typed.length > 0 && !options.some((o) => o.toLowerCase() === typed.toLowerCase())
|
|
@@ -383,138 +461,160 @@ export function Combobox({
|
|
|
383
461
|
<div {...rest} ref={wrapperRef} className={cn("relative", className)}>
|
|
384
462
|
{/* A real <label for> since lenkbank's tests met a field `getByLabelText` could
|
|
385
463
|
not find: it used to be a <span>, with the text copied onto `aria-label`. The
|
|
386
|
-
name a reader hears is the same words either way.
|
|
387
|
-
|
|
388
|
-
|
|
464
|
+
name a reader hears is the same words either way. With a "?" it moves into a
|
|
465
|
+
row after the field, below. */}
|
|
466
|
+
{label !== undefined && hintParts.labelHint === undefined && (
|
|
467
|
+
<ComboboxFieldLabel htmlFor={fieldId} className={inert ? "opacity-50" : undefined}>
|
|
389
468
|
{label}
|
|
390
469
|
</ComboboxFieldLabel>
|
|
391
470
|
)}
|
|
392
|
-
{
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
471
|
+
<EndHintRow {...endHintRowProps("hint" in props, label !== undefined, hintParts.labelHint)}>
|
|
472
|
+
{withLock(
|
|
473
|
+
/* The chevron centers against this inner wrapper, which hugs the input.
|
|
474
|
+
The outer div can be taller than the input (as a grid item it
|
|
475
|
+
stretches to the row height, e.g. next to the editor's category cell
|
|
476
|
+
with its split button), which used to drag a top-1/2 chevron down to
|
|
477
|
+
the input's bottom edge (feedback #248).
|
|
478
|
+
Dimmed as a whole when disabled (or locked), the way EntityCombobox dims its
|
|
479
|
+
trigger. FIELD_BASE's grey alone left a disabled picker looking like a
|
|
480
|
+
filled-in one beside the pickers that do dim (the label is not the input's
|
|
481
|
+
`peer`, so it is dimmed by hand above). */
|
|
482
|
+
<div ref={fieldRef} className={cn("relative", inert && "cursor-not-allowed opacity-50")}>
|
|
483
|
+
<input
|
|
484
|
+
id={fieldId}
|
|
485
|
+
value={value}
|
|
486
|
+
placeholder={placeholder}
|
|
487
|
+
// The caller's name, over the <label for> above — which is what names the
|
|
488
|
+
// field otherwise (dev#477: a field with neither announces only its text).
|
|
489
|
+
aria-label={ariaLabel}
|
|
490
|
+
role="combobox"
|
|
491
|
+
aria-expanded={open}
|
|
492
|
+
// The list this field is the mouth of. Required by the role, and the half
|
|
493
|
+
// that was missing: the field said it was expanded and never said what it
|
|
494
|
+
// had expanded, so a reader had no way from the box to the options
|
|
495
|
+
// (ESLint's `role-has-required-aria-props`, the audit's §a11y).
|
|
496
|
+
aria-controls={listboxId}
|
|
497
|
+
aria-activedescendant={activeId}
|
|
498
|
+
aria-autocomplete="list"
|
|
499
|
+
aria-invalid={field.isInvalid || undefined}
|
|
500
|
+
aria-describedby={mergeDescribedBy(field.describedBy, locked && lock.reasonId)}
|
|
501
|
+
aria-required={ariaRequired}
|
|
502
|
+
autoComplete="off"
|
|
503
|
+
// Locked: focusable, `readOnly`, `aria-disabled` — see `lockedKeyDown`.
|
|
504
|
+
disabled={locked ? undefined : disabled}
|
|
505
|
+
readOnly={locked || undefined}
|
|
506
|
+
aria-disabled={locked || undefined}
|
|
507
|
+
// eslint-disable-next-line jsx-a11y/no-autofocus -- a documented prop the caller opts into (off by default); the field never takes focus on its own.
|
|
508
|
+
autoFocus={autoFocus}
|
|
509
|
+
onBlur={onBlur}
|
|
510
|
+
onMouseDown={primaryOnly.onMouseDown}
|
|
511
|
+
onFocus={() => {
|
|
512
|
+
// A back/forward mouse button focuses this field on its way to
|
|
513
|
+
// navigating; it is not a request to open anything (live #309 rework).
|
|
514
|
+
// A locked field takes focus to say why, and opens nothing.
|
|
515
|
+
if (primaryOnly.fromAuxButton() || locked) return;
|
|
516
|
+
setOpen(true);
|
|
517
|
+
setTyping(false);
|
|
518
|
+
// The sheet carries its own input, so the field behind it must not also
|
|
519
|
+
// pull up the keyboard and scroll the page under the dialog.
|
|
520
|
+
if (isPhone) sheetInputRef.current?.focus();
|
|
521
|
+
}}
|
|
522
|
+
// A CLICK as well as focus — the other half of dev#549. Picking a suggestion
|
|
523
|
+
// closes the list without moving focus (the rows suppress `mousedown` on
|
|
524
|
+
// purpose, so the input never blurred), which means clicking the field again
|
|
525
|
+
// fires no `focus` event at all and the list stayed shut. "Does not open
|
|
526
|
+
// anything" was literally true.
|
|
527
|
+
onClick={() => {
|
|
528
|
+
if (locked) return;
|
|
529
|
+
setOpen(true);
|
|
530
|
+
setTyping(false);
|
|
531
|
+
}}
|
|
532
|
+
// `inputMode="none"` rather than readOnly: the field must not look
|
|
533
|
+
// uneditable (FIELD_BASE greys a read-only field since dev#468) and must
|
|
534
|
+
// still take focus — it just has no keyboard of its own, the same trick
|
|
535
|
+
// the amount field uses for the numpad.
|
|
536
|
+
inputMode={isPhone ? "none" : undefined}
|
|
537
|
+
onChange={(e) => {
|
|
538
|
+
if (locked) return;
|
|
539
|
+
onChange(e.target.value);
|
|
540
|
+
setOpen(true);
|
|
541
|
+
setActive(-1);
|
|
542
|
+
setTyping(true);
|
|
543
|
+
}}
|
|
544
|
+
onKeyDown={(e) => {
|
|
545
|
+
if (locked) return lockedKeyDown(e);
|
|
546
|
+
if (e.key === "ArrowDown") {
|
|
547
|
+
e.preventDefault();
|
|
548
|
+
setOpen(true);
|
|
549
|
+
setActive((i) => Math.min(i + 1, matches.length - 1));
|
|
550
|
+
} else if (e.key === "ArrowUp") {
|
|
551
|
+
e.preventDefault();
|
|
552
|
+
setActive((i) => Math.max(i - 1, 0));
|
|
553
|
+
} else if (e.key === "Enter") {
|
|
554
|
+
if (open && active >= 0 && active < matches.length) {
|
|
555
|
+
e.preventDefault();
|
|
556
|
+
take(matches[active]);
|
|
557
|
+
} else {
|
|
558
|
+
// No row highlighted: the typed text is the answer. `preventDefault`
|
|
559
|
+
// only when a caller is taking Enter, so a plain form submit is
|
|
560
|
+
// otherwise left alone.
|
|
561
|
+
if (onSubmit) e.preventDefault();
|
|
562
|
+
setOpen(false);
|
|
563
|
+
onSubmit?.();
|
|
564
|
+
}
|
|
565
|
+
} else if (e.key === "Escape") {
|
|
566
|
+
setOpen(false);
|
|
567
|
+
setActive(-1);
|
|
568
|
+
} else if (e.key === "Tab") {
|
|
569
|
+
// Closes, and lets the browser take the Tab: focus is in THIS input,
|
|
570
|
+
// which is staying, so there is nothing to catch — unlike the pickers
|
|
571
|
+
// whose focus sits inside a portalled panel.
|
|
572
|
+
setOpen(false);
|
|
573
|
+
setActive(-1);
|
|
574
|
+
}
|
|
575
|
+
// Home/End are deliberately absent. The APG gives them to the list only
|
|
576
|
+
// where the combobox is not editable; here the text IS the value, a
|
|
577
|
+
// payee name is long enough to want the caret moved to its start, and
|
|
578
|
+
// the desktop list is capped at 8 rows — so jumping it would be worth
|
|
579
|
+
// almost nothing and would cost the one gesture that field is used with.
|
|
580
|
+
}}
|
|
581
|
+
className={cn(
|
|
582
|
+
FIELD_BASE,
|
|
583
|
+
label !== undefined && FIELD_FLOATING_PAD,
|
|
584
|
+
"pe-9",
|
|
585
|
+
field.isInvalid && FIELD_INVALID,
|
|
586
|
+
)}
|
|
587
|
+
/>
|
|
588
|
+
<ChevronDown
|
|
589
|
+
aria-hidden
|
|
590
|
+
onMouseDown={(e) => {
|
|
591
|
+
// Toggle on the chevron without stealing focus from the input.
|
|
467
592
|
e.preventDefault();
|
|
468
|
-
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
//
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
// payee name is long enough to want the caret moved to its start, and
|
|
490
|
-
// the desktop list is capped at 8 rows — so jumping it would be worth
|
|
491
|
-
// almost nothing and would cost the one gesture that field is used with.
|
|
492
|
-
}}
|
|
493
|
-
className={cn(
|
|
494
|
-
FIELD_BASE,
|
|
495
|
-
label !== undefined && FIELD_FLOATING_PAD,
|
|
496
|
-
"pe-9",
|
|
497
|
-
field.isInvalid && FIELD_INVALID,
|
|
498
|
-
)}
|
|
499
|
-
/>
|
|
500
|
-
<ChevronDown
|
|
501
|
-
aria-hidden
|
|
502
|
-
onMouseDown={(e) => {
|
|
503
|
-
// Toggle on the chevron without stealing focus from the input.
|
|
504
|
-
e.preventDefault();
|
|
505
|
-
if (!disabled) setOpen((o) => !o);
|
|
506
|
-
}}
|
|
507
|
-
className={cn(
|
|
508
|
-
"absolute end-2.5 top-1/2 size-4 -translate-y-1/2 text-[var(--text-placeholder)]",
|
|
509
|
-
// The box above does the dimming; dimming here too would halve it again.
|
|
510
|
-
!disabled && "cursor-pointer",
|
|
511
|
-
)}
|
|
512
|
-
/>
|
|
513
|
-
</div>
|
|
593
|
+
if (!inert) setOpen((o) => !o);
|
|
594
|
+
}}
|
|
595
|
+
className={cn(
|
|
596
|
+
"absolute end-2.5 top-1/2 size-4 -translate-y-1/2 text-[var(--text-placeholder)]",
|
|
597
|
+
// The box above does the dimming; dimming here too would halve it again.
|
|
598
|
+
!inert && "cursor-pointer",
|
|
599
|
+
)}
|
|
600
|
+
/>
|
|
601
|
+
</div>,
|
|
602
|
+
)}
|
|
603
|
+
</EndHintRow>
|
|
604
|
+
{/* The label and its "?" share the top strip — after the field in the DOM, so the
|
|
605
|
+
"?" follows the control in the tab order, as it does on Input and Select. */}
|
|
606
|
+
{label !== undefined && hintParts.labelHint !== undefined && (
|
|
607
|
+
<StaticLabelRow hint={hintParts.labelHint}>
|
|
608
|
+
<ComboboxFieldLabel htmlFor={fieldId} className={cn(LABEL_IN_ROW, inert && "opacity-50")}>
|
|
609
|
+
{label}
|
|
610
|
+
</ComboboxFieldLabel>
|
|
611
|
+
</StaticLabelRow>
|
|
612
|
+
)}
|
|
613
|
+
<FieldCaption parts={hintParts} />
|
|
514
614
|
{field.errorEl}
|
|
515
615
|
{isPhone && (
|
|
516
616
|
<PickerSheet
|
|
517
|
-
open={open && !
|
|
617
|
+
open={open && !inert}
|
|
518
618
|
onClose={() => setOpen(false)}
|
|
519
619
|
title={label}
|
|
520
620
|
// The sheet's input IS the field: this is a free-text control, so what is
|
|
@@ -539,7 +639,7 @@ export function Combobox({
|
|
|
539
639
|
type="button"
|
|
540
640
|
role="option"
|
|
541
641
|
aria-selected={false}
|
|
542
|
-
onClick={() =>
|
|
642
|
+
onClick={() => take(typed)}
|
|
543
643
|
className={cn(SHEET_ROW_CLASS, "font-medium text-[var(--brand)]")}
|
|
544
644
|
>
|
|
545
645
|
{createRow}
|
|
@@ -563,7 +663,7 @@ export function Combobox({
|
|
|
563
663
|
type="button"
|
|
564
664
|
role="option"
|
|
565
665
|
aria-selected={o === value}
|
|
566
|
-
onClick={() =>
|
|
666
|
+
onClick={() => take(o)}
|
|
567
667
|
className={cn(
|
|
568
668
|
SHEET_ROW_CLASS,
|
|
569
669
|
optionAdornment && "flex items-center justify-between gap-2",
|
|
@@ -579,7 +679,7 @@ export function Combobox({
|
|
|
579
679
|
</ul>
|
|
580
680
|
</PickerSheet>
|
|
581
681
|
)}
|
|
582
|
-
{!isPhone && open && !
|
|
682
|
+
{!isPhone && open && !inert && (matches.length > 0 || createRow) && (
|
|
583
683
|
<SuggestionList id={listboxId} anchorRef={fieldRef} panelRef={panelRef}>
|
|
584
684
|
{createRow && (
|
|
585
685
|
<li role="presentation">
|
|
@@ -592,7 +692,7 @@ export function Combobox({
|
|
|
592
692
|
// mousedown, like the rows below: the input's blur would otherwise
|
|
593
693
|
// close the list before the click landed.
|
|
594
694
|
e.preventDefault();
|
|
595
|
-
|
|
695
|
+
take(typed);
|
|
596
696
|
}}
|
|
597
697
|
className={cn(rowClass(false), "font-medium text-[var(--brand)]")}
|
|
598
698
|
>
|
|
@@ -632,7 +732,7 @@ export function Combobox({
|
|
|
632
732
|
// mousedown (not click) so the blur from the input firing first
|
|
633
733
|
// doesn't close the list before the selection registers.
|
|
634
734
|
e.preventDefault();
|
|
635
|
-
|
|
735
|
+
take(o);
|
|
636
736
|
}}
|
|
637
737
|
onMouseEnter={() => setActive(i)}
|
|
638
738
|
className={cn(
|
|
@@ -667,6 +767,8 @@ export interface InlineEntityComboboxProps<V extends string | number, C extends
|
|
|
667
767
|
invalid?: boolean;
|
|
668
768
|
/** What is wrong with the value — see {@link Combobox}'s `error`. */
|
|
669
769
|
error?: ReactNode;
|
|
770
|
+
/** Standing advice — see {@link Combobox}'s `hint` (keksdose K4). */
|
|
771
|
+
hint?: ReactNode;
|
|
670
772
|
/** A picked/typed option emits its id; emptying the field, or the "×", emits
|
|
671
773
|
* `clearValue`. */
|
|
672
774
|
onChange: (v: V | C) => void;
|
|
@@ -702,6 +804,72 @@ export interface InlineEntityComboboxProps<V extends string | number, C extends
|
|
|
702
804
|
* affordance both shells share, and it clears without opening anything. */
|
|
703
805
|
clearable?: boolean;
|
|
704
806
|
clearLabel?: string;
|
|
807
|
+
/**
|
|
808
|
+
* Why the choice cannot be changed — {@link Button}'s `disabledReason`, for a picker
|
|
809
|
+
* that SAVES on change. keksdose G2: the statement review's account picker IS the
|
|
810
|
+
* write (picking re-runs the duplicate check on the server), and on the read-only demo
|
|
811
|
+
* the app wrapped it in a hand-made Tooltip over a native `disabled` — out of the tab
|
|
812
|
+
* order, so the reason never reached a keyboard.
|
|
813
|
+
*
|
|
814
|
+
* With a reason the input stays focusable and `aria-disabled`, is `readOnly` (the
|
|
815
|
+
* settled grey), opens no list or sheet, offers no clear "×", and no pick, typed
|
|
816
|
+
* label or clear reaches `onChange`; the reason is in the kit {@link Tooltip} and on
|
|
817
|
+
* the input's `aria-describedby`. Wins over `disabled`. Use it CONTROLLED, as the
|
|
818
|
+
* component always is: the shown label is `value`'s.
|
|
819
|
+
*/
|
|
820
|
+
disabledReason?: ReactNode;
|
|
821
|
+
/**
|
|
822
|
+
* This picker COMMITS — choosing saves. Under a locked {@link WriteLockProvider} it is
|
|
823
|
+
* locked the `disabledReason` way with the lock's reason (which wins over its own).
|
|
824
|
+
* A picker inside a form with its own Save stays editable under the lock — leave this
|
|
825
|
+
* off there and put `commit` on the Save. No provider, or an unlocked one: no effect.
|
|
826
|
+
*/
|
|
827
|
+
commit?: boolean;
|
|
828
|
+
/**
|
|
829
|
+
* A last row that makes a new record from what was typed — keksdose G9, whose "Create
|
|
830
|
+
* cash account" had to become a separate button because this picker had no such row.
|
|
831
|
+
*
|
|
832
|
+
* Shown while the typed query is non-empty and names no option exactly
|
|
833
|
+
* (case-insensitive — an exact hit is a record the list already shows), worded
|
|
834
|
+
* `createLabel(query)`, default `combobox.create`: "Create “{query}”". The arrow keys
|
|
835
|
+
* reach it like an option; Enter or a click calls `onCreate(query)` and closes the
|
|
836
|
+
* list. Making the record, and then passing its id as `value`, is the caller's: the
|
|
837
|
+
* field shows the old selection until it does.
|
|
838
|
+
*
|
|
839
|
+
* The same rule as {@link EntityCombobox}'s `onCreate`, so the family agrees. It is a
|
|
840
|
+
* row of the LIST, not an option: it is never `aria-selected`, never matched by a
|
|
841
|
+
* typed label on blur, and never sorted among the records the way a sentinel option
|
|
842
|
+
* would be (the reason keksdose gave for the separate button).
|
|
843
|
+
*/
|
|
844
|
+
onCreate?: (query: string) => void;
|
|
845
|
+
/** The create row's words for a query. Default: `combobox.create` from the
|
|
846
|
+
* {@link UiKitProvider}, else English `Create “{query}”`. */
|
|
847
|
+
createLabel?: (query: string) => string;
|
|
848
|
+
/**
|
|
849
|
+
* Offer the create row with NOTHING typed too, worded as this — keksdose's "Create cash
|
|
850
|
+
* account", which mints an account under a default name and so needs no query.
|
|
851
|
+
* `onCreate` then receives `""`, and the caller supplies the name. Left out, the row
|
|
852
|
+
* needs a query.
|
|
853
|
+
*
|
|
854
|
+
* One string rather than a switch: a row with no query has nothing to quote, so the
|
|
855
|
+
* kit cannot word it — "Create “”" says nothing — and naming it is what turns it on.
|
|
856
|
+
* With both, one handler serves both rows:
|
|
857
|
+
*
|
|
858
|
+
* ```tsx
|
|
859
|
+
* onCreate={(name) => createCash(name || t("cash_account_default_name"))}
|
|
860
|
+
* createEmptyLabel={t("create_cash_account")}
|
|
861
|
+
* ```
|
|
862
|
+
*/
|
|
863
|
+
createEmptyLabel?: string;
|
|
864
|
+
/**
|
|
865
|
+
* The create row WRITES while picking does not — keksdose dev#496: on the read-only
|
|
866
|
+
* demo choosing the cash account is draft state and stays live, minting one is a
|
|
867
|
+
* write. Under a locked {@link WriteLockProvider} the row stays in the list, dimmed and
|
|
868
|
+
* passed over by the arrows, with the lock's reason as its second line (a hover is the
|
|
869
|
+
* one explanation a phone cannot show), and `onCreate` is not called. Not needed with
|
|
870
|
+
* `commit`: a locked field opens no list at all.
|
|
871
|
+
*/
|
|
872
|
+
createCommit?: boolean;
|
|
705
873
|
}
|
|
706
874
|
|
|
707
875
|
/**
|
|
@@ -722,40 +890,51 @@ export interface InlineEntityComboboxProps<V extends string | number, C extends
|
|
|
722
890
|
* sheet that emptied it to show the full list would read as "the user cleared the
|
|
723
891
|
* field" the moment it closed.
|
|
724
892
|
*/
|
|
725
|
-
export function InlineEntityCombobox<V extends string | number, C extends ComboClearValue = null>(
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
893
|
+
export function InlineEntityCombobox<V extends string | number, C extends ComboClearValue = null>(
|
|
894
|
+
props: InlineEntityComboboxProps<V, C>,
|
|
895
|
+
) {
|
|
896
|
+
const {
|
|
897
|
+
value: rawValue,
|
|
898
|
+
onChange,
|
|
899
|
+
clearValue = null as C,
|
|
900
|
+
options,
|
|
901
|
+
label,
|
|
902
|
+
id,
|
|
903
|
+
placeholder,
|
|
904
|
+
className,
|
|
905
|
+
disabled,
|
|
906
|
+
autoFocus,
|
|
907
|
+
searchPlaceholder,
|
|
908
|
+
emptyLabel,
|
|
909
|
+
closeLabel,
|
|
910
|
+
clearable,
|
|
911
|
+
clearLabel,
|
|
912
|
+
disabledReason,
|
|
913
|
+
commit,
|
|
914
|
+
onCreate,
|
|
915
|
+
createLabel,
|
|
916
|
+
createEmptyLabel,
|
|
917
|
+
createCommit,
|
|
918
|
+
invalid,
|
|
919
|
+
error,
|
|
920
|
+
hint,
|
|
921
|
+
"aria-label": ariaLabel,
|
|
922
|
+
// Off `rest` and onto the <input>, which is the combobox a reader meets: `Field`'s
|
|
923
|
+
// render-prop spreads `{ id, aria-describedby, aria-invalid, aria-required }`, and
|
|
924
|
+
// on the wrapper div the hint and required state described nothing.
|
|
925
|
+
"aria-describedby": ariaDescribedBy,
|
|
926
|
+
"aria-invalid": ariaInvalid,
|
|
927
|
+
"aria-required": ariaRequired,
|
|
928
|
+
...rest
|
|
929
|
+
} = props;
|
|
752
930
|
// The clear value reads as "nothing selected", whichever one the caller picked —
|
|
753
931
|
// so from here down `value` is the id or `null`, as it always was.
|
|
754
932
|
const value: V | null = rawValue == null || rawValue === clearValue ? null : (rawValue as V);
|
|
933
|
+
const hintParts = useFieldHint(hint, ariaDescribedBy);
|
|
755
934
|
const field = useComboboxFieldError(
|
|
756
935
|
error,
|
|
757
936
|
invalid || ariaInvalid === true || ariaInvalid === "true",
|
|
758
|
-
|
|
937
|
+
hintParts.describedBy,
|
|
759
938
|
);
|
|
760
939
|
const generated = useId();
|
|
761
940
|
const fieldId = id ?? generated;
|
|
@@ -783,7 +962,21 @@ export function InlineEntityCombobox<V extends string | number, C extends ComboC
|
|
|
783
962
|
search: searchPlaceholder,
|
|
784
963
|
noResults: emptyLabel,
|
|
785
964
|
clear: clearLabel,
|
|
965
|
+
create: createLabel,
|
|
786
966
|
});
|
|
967
|
+
// The lock (see `lockedKeyDown` above). A lock arriving mid-edit closes the list and
|
|
968
|
+
// drops the loose text, adjusted while rendering: nothing typed before it may be
|
|
969
|
+
// judged by `reconcile` after it.
|
|
970
|
+
const lock = useLockReason(commit, disabledReason);
|
|
971
|
+
const locked = lock.locked;
|
|
972
|
+
const inert = locked || Boolean(disabled);
|
|
973
|
+
if (locked && (open || text !== null)) {
|
|
974
|
+
setOpen(false);
|
|
975
|
+
setText(null);
|
|
976
|
+
}
|
|
977
|
+
// The create row's own lock (`createCommit`): the list stays live, the row does not.
|
|
978
|
+
const createReason = useCommitReason(createCommit, undefined);
|
|
979
|
+
const createLocked = hasMessage(createReason);
|
|
787
980
|
|
|
788
981
|
const selected = useMemo(
|
|
789
982
|
() => (value == null ? null : (options.find((o) => o.value === value) ?? null)),
|
|
@@ -792,7 +985,7 @@ export function InlineEntityCombobox<V extends string | number, C extends ComboC
|
|
|
792
985
|
const shown = text ?? selected?.label ?? "";
|
|
793
986
|
// Nothing selected has nothing to clear, and the chevron comes back — the field
|
|
794
987
|
// keeps exactly one trailing control, so the "×" never crowds the value it sits on.
|
|
795
|
-
const showClear = Boolean(clearable && value != null && !
|
|
988
|
+
const showClear = Boolean(clearable && value != null && !inert);
|
|
796
989
|
|
|
797
990
|
// Focusing select-alls the current label; filtering only kicks in once the
|
|
798
991
|
// text actually differs from it, so an already-filled field still opens on
|
|
@@ -826,11 +1019,35 @@ export function InlineEntityCombobox<V extends string | number, C extends ComboC
|
|
|
826
1019
|
return [...blocks.values()].flat();
|
|
827
1020
|
}, [options, query]);
|
|
828
1021
|
|
|
1022
|
+
// The create row (`onCreate`), on the query as typed — not lowercased: it becomes a
|
|
1023
|
+
// name. Compared against EVERY option, not `matches`: an exact hit anywhere is a
|
|
1024
|
+
// record that exists, disabled or not. Restated from `text` rather than derived from
|
|
1025
|
+
// `typedQuery`: this value is handed to the caller's `onCreate`, and the React
|
|
1026
|
+
// Compiler cannot then keep `matches` memoised on anything it was derived from.
|
|
1027
|
+
const createQuery = isPhone
|
|
1028
|
+
? sheetQuery.trim()
|
|
1029
|
+
: text !== null && text !== (selected?.label ?? "")
|
|
1030
|
+
? text.trim()
|
|
1031
|
+
: "";
|
|
1032
|
+
const showCreate = offersCreate(
|
|
1033
|
+
onCreate,
|
|
1034
|
+
createQuery,
|
|
1035
|
+
options.map((o) => o.label),
|
|
1036
|
+
createEmptyLabel,
|
|
1037
|
+
);
|
|
1038
|
+
const createText = createQuery ? labels.create(createQuery) : createEmptyLabel;
|
|
1039
|
+
// Every row the keyboard can land on: the options, then the create row — passed over
|
|
1040
|
+
// like a disabled option while a write lock holds it.
|
|
1041
|
+
const rows: readonly { disabled?: boolean }[] = showCreate
|
|
1042
|
+
? [...matches, { disabled: createLocked }]
|
|
1043
|
+
: matches;
|
|
1044
|
+
const createIndex = matches.length;
|
|
1045
|
+
|
|
829
1046
|
// Pointer-device only: the phone's rows live in a {@link PickerSheet} whose own
|
|
830
1047
|
// search box holds focus, so the field behind it must not claim to be pointing at
|
|
831
1048
|
// one of them.
|
|
832
1049
|
// A disabled row is never the keyboard's, even if the list changed under it.
|
|
833
|
-
const activeId = !isPhone && isOptionEnabled(
|
|
1050
|
+
const activeId = !isPhone && isOptionEnabled(rows[active]) ? optionId(active) : undefined;
|
|
834
1051
|
useActiveOptionScroll(activeId);
|
|
835
1052
|
|
|
836
1053
|
const close = () => {
|
|
@@ -838,9 +1055,10 @@ export function InlineEntityCombobox<V extends string | number, C extends ComboC
|
|
|
838
1055
|
setActive(-1);
|
|
839
1056
|
setSheetQuery("");
|
|
840
1057
|
};
|
|
841
|
-
const
|
|
842
|
-
// A `disabled` option is listed, never taken — by any path
|
|
843
|
-
|
|
1058
|
+
const take = (o: ComboOption<V>) => {
|
|
1059
|
+
// A `disabled` option is listed, never taken — by any path; nor is anything while
|
|
1060
|
+
// the field is locked.
|
|
1061
|
+
if (o.disabled || locked) return;
|
|
844
1062
|
if (o.value !== value) onChange(o.value);
|
|
845
1063
|
setText(null);
|
|
846
1064
|
close();
|
|
@@ -855,8 +1073,15 @@ export function InlineEntityCombobox<V extends string | number, C extends ComboC
|
|
|
855
1073
|
* used, then tabbing away, silently discarded it, and the more categories you used
|
|
856
1074
|
* the more of them stopped working. Two rows naming the same id are not an ambiguity;
|
|
857
1075
|
* two ids sharing a label are. */
|
|
1076
|
+
const create = () => {
|
|
1077
|
+
if (!onCreate || locked || createLocked) return;
|
|
1078
|
+
onCreate(createQuery);
|
|
1079
|
+
setText(null);
|
|
1080
|
+
close();
|
|
1081
|
+
};
|
|
858
1082
|
const reconcile = () => {
|
|
859
|
-
|
|
1083
|
+
// Locked, loose text is dropped rather than judged: nothing reaches `onChange`.
|
|
1084
|
+
if (text !== null && !locked) {
|
|
860
1085
|
const q = text.trim();
|
|
861
1086
|
if (!q) {
|
|
862
1087
|
if (value != null) onChange(clearValue);
|
|
@@ -869,164 +1094,209 @@ export function InlineEntityCombobox<V extends string | number, C extends ComboC
|
|
|
869
1094
|
const ids = new Set(hits.map((h) => h.value));
|
|
870
1095
|
if (ids.size === 1 && hits[0].value !== value) onChange(hits[0].value);
|
|
871
1096
|
}
|
|
872
|
-
setText(null);
|
|
873
1097
|
}
|
|
1098
|
+
setText(null);
|
|
874
1099
|
close();
|
|
875
1100
|
};
|
|
876
1101
|
|
|
1102
|
+
// The create row's inside, one for both shells: a "+" (it is an action, not a record),
|
|
1103
|
+
// the words, and — while a write lock holds it — the lock's reason as a second line.
|
|
1104
|
+
const createRowContent = (
|
|
1105
|
+
<>
|
|
1106
|
+
<Plus aria-hidden className="size-4 shrink-0" />
|
|
1107
|
+
<span className="min-w-0 flex-1">
|
|
1108
|
+
<span className="block truncate">{createText}</span>
|
|
1109
|
+
{createLocked && (
|
|
1110
|
+
<span className="block truncate text-xs font-normal text-[var(--text-placeholder)]">
|
|
1111
|
+
{createReason}
|
|
1112
|
+
</span>
|
|
1113
|
+
)}
|
|
1114
|
+
</span>
|
|
1115
|
+
</>
|
|
1116
|
+
);
|
|
1117
|
+
// While locked the field sits in the reason's Tooltip — see Combobox.
|
|
1118
|
+
const withLock = (box: ReactNode) =>
|
|
1119
|
+
locked ? (
|
|
1120
|
+
<LockedReason lock={lock} className="block">
|
|
1121
|
+
{box}
|
|
1122
|
+
</LockedReason>
|
|
1123
|
+
) : (
|
|
1124
|
+
box
|
|
1125
|
+
);
|
|
1126
|
+
|
|
877
1127
|
return (
|
|
878
1128
|
// `rest` dresses the outer box — a `data-tour` anchor, a test id. Not the NAME,
|
|
879
1129
|
// description, invalid or required state: those belong on the <input> below,
|
|
880
1130
|
// which is the combobox a reader meets. Spread FIRST, so the field's ARIA and the
|
|
881
1131
|
// handlers carrying live #309 and dev#549 cannot be replaced from outside.
|
|
882
1132
|
<div {...rest} ref={wrapperRef} className={cn("relative", className)}>
|
|
883
|
-
{/* A real <label for> — see {@link Combobox}. */}
|
|
884
|
-
{label !== undefined && (
|
|
885
|
-
<ComboboxFieldLabel htmlFor={fieldId} className={
|
|
1133
|
+
{/* A real <label for> — see {@link Combobox}; with a "?", in a row after the field. */}
|
|
1134
|
+
{label !== undefined && hintParts.labelHint === undefined && (
|
|
1135
|
+
<ComboboxFieldLabel htmlFor={fieldId} className={inert ? "opacity-50" : undefined}>
|
|
886
1136
|
{label}
|
|
887
1137
|
</ComboboxFieldLabel>
|
|
888
1138
|
)}
|
|
889
|
-
{
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
e.
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1139
|
+
<EndHintRow {...endHintRowProps("hint" in props, label !== undefined, hintParts.labelHint)}>
|
|
1140
|
+
{withLock(
|
|
1141
|
+
/* Inner wrapper for chevron centering — same reasoning as Combobox above.
|
|
1142
|
+
It is also what the portalled list anchors to.
|
|
1143
|
+
Dimmed as a whole when disabled (or locked), the way EntityCombobox dims its
|
|
1144
|
+
trigger. FIELD_BASE's grey alone left a disabled picker looking like a
|
|
1145
|
+
filled-in one beside the pickers that do dim (the label is not the input's
|
|
1146
|
+
`peer`, so it is dimmed by hand above). */
|
|
1147
|
+
<div ref={fieldRef} className={cn("relative", inert && "cursor-not-allowed opacity-50")}>
|
|
1148
|
+
<input
|
|
1149
|
+
id={fieldId}
|
|
1150
|
+
value={shown}
|
|
1151
|
+
placeholder={placeholder}
|
|
1152
|
+
// The caller's name, over the <label for> above. Just the label, never
|
|
1153
|
+
// "label: value" the way a trigger button has to compose it: an input
|
|
1154
|
+
// already exposes its value separately.
|
|
1155
|
+
aria-label={ariaLabel}
|
|
1156
|
+
role="combobox"
|
|
1157
|
+
aria-expanded={open}
|
|
1158
|
+
// Required by the role, and the half that was missing: the field said it
|
|
1159
|
+
// was expanded and never said what it had expanded (ESLint's
|
|
1160
|
+
// `role-has-required-aria-props`, the audit's §a11y).
|
|
1161
|
+
aria-controls={listboxId}
|
|
1162
|
+
aria-activedescendant={activeId}
|
|
1163
|
+
aria-autocomplete="list"
|
|
1164
|
+
aria-invalid={field.isInvalid || undefined}
|
|
1165
|
+
aria-describedby={mergeDescribedBy(field.describedBy, locked && lock.reasonId)}
|
|
1166
|
+
aria-required={ariaRequired}
|
|
1167
|
+
autoComplete="off"
|
|
1168
|
+
// Locked: focusable, `readOnly`, `aria-disabled` — see `lockedKeyDown`.
|
|
1169
|
+
disabled={locked ? undefined : disabled}
|
|
1170
|
+
readOnly={locked || undefined}
|
|
1171
|
+
aria-disabled={locked || undefined}
|
|
1172
|
+
// eslint-disable-next-line jsx-a11y/no-autofocus -- a documented prop the caller opts into (off by default); the field never takes focus on its own.
|
|
1173
|
+
autoFocus={autoFocus}
|
|
1174
|
+
// `inputMode="none"` rather than readOnly, for the same reason Combobox
|
|
1175
|
+
// above gives: the sheet carries the keyboard, and a readOnly field would
|
|
1176
|
+
// take FIELD_BASE's settled look on a field that is perfectly editable.
|
|
1177
|
+
inputMode={isPhone ? "none" : undefined}
|
|
1178
|
+
onMouseDown={primaryOnly.onMouseDown}
|
|
1179
|
+
onFocus={(e) => {
|
|
1180
|
+
// See {@link usePrimaryPressOnly}: a back/forward button lands here on
|
|
1181
|
+
// its way to navigating, and neither the list nor the select-all is
|
|
1182
|
+
// anything it asked for (live #309 rework). A locked field takes focus to
|
|
1183
|
+
// say why, and opens nothing.
|
|
1184
|
+
if (primaryOnly.fromAuxButton() || locked) return;
|
|
1185
|
+
setText(shown);
|
|
1186
|
+
e.currentTarget.select();
|
|
1187
|
+
setOpen(true);
|
|
1188
|
+
// The sheet has its own input; the field behind it must not also pull up
|
|
1189
|
+
// the keyboard and scroll the page under the dialog.
|
|
1190
|
+
if (isPhone) sheetInputRef.current?.focus();
|
|
1191
|
+
}}
|
|
1192
|
+
onBlur={() => {
|
|
1193
|
+
// On a phone the blur is the SHEET taking focus, not the user leaving the
|
|
1194
|
+
// field — reconciling there would close the sheet the instant it opened.
|
|
1195
|
+
if (!isPhone) reconcile();
|
|
1196
|
+
}}
|
|
1197
|
+
onChange={(e) => {
|
|
1198
|
+
if (locked) return;
|
|
1199
|
+
setText(e.target.value);
|
|
1200
|
+
setOpen(true);
|
|
1201
|
+
setActive(-1);
|
|
1202
|
+
}}
|
|
1203
|
+
onKeyDown={(e) => {
|
|
1204
|
+
if (locked) return lockedKeyDown(e);
|
|
1205
|
+
// Disabled rows are passed over; Up from "nothing highlighted" lands on
|
|
1206
|
+
// the first takeable row, as it always landed on row 0. The create row is
|
|
1207
|
+
// the last row the arrows reach.
|
|
1208
|
+
if (e.key === "ArrowDown") {
|
|
1209
|
+
e.preventDefault();
|
|
1210
|
+
setOpen(true);
|
|
1211
|
+
setActive((i) => stepEnabled(rows, i, 1));
|
|
1212
|
+
} else if (e.key === "ArrowUp") {
|
|
1213
|
+
e.preventDefault();
|
|
1214
|
+
setActive((i) => (i < 0 ? stepEnabled(rows, -1, 1) : stepEnabled(rows, i, -1)));
|
|
1215
|
+
} else if (e.key === "Enter") {
|
|
1216
|
+
if (open && isOptionEnabled(rows[active])) {
|
|
1217
|
+
e.preventDefault();
|
|
1218
|
+
if (active === createIndex && showCreate) create();
|
|
1219
|
+
else take(matches[active]);
|
|
1220
|
+
} else {
|
|
1221
|
+
reconcile();
|
|
1222
|
+
}
|
|
1223
|
+
} else if (e.key === "Escape") {
|
|
1224
|
+
setText(null);
|
|
1225
|
+
close();
|
|
1226
|
+
} else if (e.key === "Tab") {
|
|
1227
|
+
// Focus is in THIS input and stays there, so the browser's own Tab is
|
|
1228
|
+
// left alone; all that is needed is that the list stop covering what
|
|
1229
|
+
// the user is tabbing to. `reconcile` rather than `close`, because
|
|
1230
|
+
// leaving the field is exactly when loose text has to be judged.
|
|
1231
|
+
reconcile();
|
|
1232
|
+
}
|
|
1233
|
+
// Home/End stay with the caret — see the note in {@link Combobox}: this
|
|
1234
|
+
// field is editable, and its text is what `reconcile` judges.
|
|
1235
|
+
}}
|
|
1236
|
+
className={cn(
|
|
1237
|
+
FIELD_BASE,
|
|
1238
|
+
label !== undefined && FIELD_FLOATING_PAD,
|
|
1239
|
+
"pe-9",
|
|
1240
|
+
field.isInvalid && FIELD_INVALID,
|
|
1241
|
+
)}
|
|
1242
|
+
/>
|
|
1243
|
+
{showClear ? (
|
|
1244
|
+
<button
|
|
1245
|
+
type="button"
|
|
1246
|
+
// Out of the tab order, like the clear on `EntityCombobox`: the keyboard
|
|
1247
|
+
// already clears this field by selecting its text and deleting, and a
|
|
1248
|
+
// second stop between every picker and the next field is a worse trade
|
|
1249
|
+
// than the one gesture it saves.
|
|
1250
|
+
tabIndex={-1}
|
|
1251
|
+
aria-label={labels.clear}
|
|
1252
|
+
// preventDefault, exactly as the chevron does: without it the press
|
|
1253
|
+
// focuses the input, which on a phone opens the sheet over the field the
|
|
1254
|
+
// press was clearing.
|
|
1255
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
1256
|
+
onClick={() => {
|
|
1257
|
+
setText(null);
|
|
1258
|
+
close();
|
|
1259
|
+
if (!locked) onChange(clearValue);
|
|
1260
|
+
}}
|
|
1261
|
+
className={cn(
|
|
1262
|
+
"absolute end-2 top-1/2 -translate-y-1/2 rounded p-0.5 text-[var(--text-placeholder)]",
|
|
1263
|
+
"hover:text-[var(--text-secondary)]",
|
|
1264
|
+
)}
|
|
1265
|
+
>
|
|
1266
|
+
<X aria-hidden className="size-4" />
|
|
1267
|
+
</button>
|
|
1268
|
+
) : (
|
|
1269
|
+
<ChevronDown
|
|
1270
|
+
aria-hidden
|
|
1271
|
+
onMouseDown={(e) => {
|
|
1272
|
+
// Toggle on the chevron without stealing focus from the input — and
|
|
1273
|
+
// not at all on a disabled or locked field, which the input's own
|
|
1274
|
+
// `disabled`/`readOnly` does not stop from here.
|
|
1275
|
+
e.preventDefault();
|
|
1276
|
+
if (!inert) setOpen((o) => !o);
|
|
1277
|
+
}}
|
|
1278
|
+
className={cn(
|
|
1279
|
+
"absolute end-2.5 top-1/2 size-4 -translate-y-1/2 text-[var(--text-placeholder)]",
|
|
1280
|
+
// The box above does the dimming; dimming here too would halve it again.
|
|
1281
|
+
!inert && "cursor-pointer",
|
|
1282
|
+
)}
|
|
1283
|
+
/>
|
|
1022
1284
|
)}
|
|
1023
|
-
|
|
1285
|
+
</div>,
|
|
1024
1286
|
)}
|
|
1025
|
-
</
|
|
1287
|
+
</EndHintRow>
|
|
1288
|
+
{label !== undefined && hintParts.labelHint !== undefined && (
|
|
1289
|
+
<StaticLabelRow hint={hintParts.labelHint}>
|
|
1290
|
+
<ComboboxFieldLabel htmlFor={fieldId} className={cn(LABEL_IN_ROW, inert && "opacity-50")}>
|
|
1291
|
+
{label}
|
|
1292
|
+
</ComboboxFieldLabel>
|
|
1293
|
+
</StaticLabelRow>
|
|
1294
|
+
)}
|
|
1295
|
+
<FieldCaption parts={hintParts} />
|
|
1026
1296
|
{field.errorEl}
|
|
1027
1297
|
{isPhone && (
|
|
1028
1298
|
<PickerSheet
|
|
1029
|
-
open={open && !
|
|
1299
|
+
open={open && !inert}
|
|
1030
1300
|
// Closing without choosing keeps the value: `text` was never emptied, so
|
|
1031
1301
|
// reconcile has nothing to undo — it just puts the label back.
|
|
1032
1302
|
onClose={reconcile}
|
|
@@ -1060,7 +1330,7 @@ export function InlineEntityCombobox<V extends string | number, C extends ComboC
|
|
|
1060
1330
|
role="option"
|
|
1061
1331
|
aria-selected={o.value === value}
|
|
1062
1332
|
aria-disabled={o.disabled || undefined}
|
|
1063
|
-
onClick={() =>
|
|
1333
|
+
onClick={() => take(o)}
|
|
1064
1334
|
className={cn(
|
|
1065
1335
|
SHEET_ROW_CLASS,
|
|
1066
1336
|
o.value === value && "font-medium",
|
|
@@ -1072,13 +1342,34 @@ export function InlineEntityCombobox<V extends string | number, C extends ComboC
|
|
|
1072
1342
|
</li>
|
|
1073
1343
|
</Fragment>
|
|
1074
1344
|
))}
|
|
1075
|
-
{
|
|
1345
|
+
{showCreate && (
|
|
1346
|
+
<li role="presentation">
|
|
1347
|
+
<button
|
|
1348
|
+
type="button"
|
|
1349
|
+
role="option"
|
|
1350
|
+
// An action, never chosen — see `onCreate`.
|
|
1351
|
+
aria-selected={false}
|
|
1352
|
+
aria-disabled={createLocked || undefined}
|
|
1353
|
+
onClick={create}
|
|
1354
|
+
className={cn(
|
|
1355
|
+
SHEET_ROW_CLASS,
|
|
1356
|
+
CREATE_ROW_CLASS,
|
|
1357
|
+
createLocked && DISABLED_ROW_CLASS,
|
|
1358
|
+
)}
|
|
1359
|
+
>
|
|
1360
|
+
{createRowContent}
|
|
1361
|
+
</button>
|
|
1362
|
+
</li>
|
|
1363
|
+
)}
|
|
1364
|
+
{/* "Nothing matched" only when there is nothing to do either: with a create
|
|
1365
|
+
row the list is not empty, it is offering to fill itself. */}
|
|
1366
|
+
{matches.length === 0 && !showCreate && (
|
|
1076
1367
|
<li className="px-4 py-3 text-sm text-[var(--text-muted)]">{labels.noResults}</li>
|
|
1077
1368
|
)}
|
|
1078
1369
|
</ul>
|
|
1079
1370
|
</PickerSheet>
|
|
1080
1371
|
)}
|
|
1081
|
-
{!isPhone && open && !
|
|
1372
|
+
{!isPhone && open && !inert && (matches.length > 0 || showCreate) && (
|
|
1082
1373
|
<SuggestionList id={listboxId} anchorRef={fieldRef} panelRef={panelRef}>
|
|
1083
1374
|
{matches.map((o, i) => (
|
|
1084
1375
|
// A group heading is emitted at each group boundary rather than repeating
|
|
@@ -1112,7 +1403,7 @@ export function InlineEntityCombobox<V extends string | number, C extends ComboC
|
|
|
1112
1403
|
// list before the selection registers. Prevented on a disabled
|
|
1113
1404
|
// row too, so pressing one leaves the field focused and open.
|
|
1114
1405
|
e.preventDefault();
|
|
1115
|
-
|
|
1406
|
+
take(o);
|
|
1116
1407
|
}}
|
|
1117
1408
|
onMouseEnter={() => {
|
|
1118
1409
|
if (!o.disabled) setActive(i);
|
|
@@ -1136,6 +1427,36 @@ export function InlineEntityCombobox<V extends string | number, C extends ComboC
|
|
|
1136
1427
|
</li>
|
|
1137
1428
|
</Fragment>
|
|
1138
1429
|
))}
|
|
1430
|
+
{showCreate && (
|
|
1431
|
+
<li role="presentation">
|
|
1432
|
+
<button
|
|
1433
|
+
type="button"
|
|
1434
|
+
id={optionId(createIndex)}
|
|
1435
|
+
role="option"
|
|
1436
|
+
// An action, never chosen; whether the keyboard is ON it is the field's
|
|
1437
|
+
// `aria-activedescendant`'s to say.
|
|
1438
|
+
aria-selected={false}
|
|
1439
|
+
aria-disabled={createLocked || undefined}
|
|
1440
|
+
tabIndex={-1}
|
|
1441
|
+
onMouseDown={(e) => {
|
|
1442
|
+
// mousedown, like the rows above: the input's blur would otherwise
|
|
1443
|
+
// reconcile and close the list before the click landed.
|
|
1444
|
+
e.preventDefault();
|
|
1445
|
+
create();
|
|
1446
|
+
}}
|
|
1447
|
+
onMouseEnter={() => {
|
|
1448
|
+
if (!createLocked) setActive(createIndex);
|
|
1449
|
+
}}
|
|
1450
|
+
className={cn(
|
|
1451
|
+
rowClass(active === createIndex && !createLocked),
|
|
1452
|
+
CREATE_ROW_CLASS,
|
|
1453
|
+
createLocked && DISABLED_ROW_CLASS,
|
|
1454
|
+
)}
|
|
1455
|
+
>
|
|
1456
|
+
{createRowContent}
|
|
1457
|
+
</button>
|
|
1458
|
+
</li>
|
|
1459
|
+
)}
|
|
1139
1460
|
</SuggestionList>
|
|
1140
1461
|
)}
|
|
1141
1462
|
</div>
|