@eifi1/ui-kit 0.34.0 → 0.35.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 +82 -38
- package/dist/billing/billing-banner.d.ts +5 -2
- package/dist/billing/billing-banner.js.map +1 -1
- package/dist/billing/plan-limit-notice.d.ts +5 -2
- package/dist/billing/plan-limit-notice.js.map +1 -1
- package/dist/chart.js.map +1 -1
- package/dist/components/amount-input.d.ts +0 -3
- package/dist/components/amount-input.js +2 -2
- package/dist/components/amount-input.js.map +1 -1
- package/dist/components/calculator.d.ts +1 -4
- package/dist/components/calculator.js +1 -3
- package/dist/components/calculator.js.map +1 -1
- package/dist/components/chart-zoom.d.ts +0 -37
- package/dist/components/chart-zoom.js +1 -28
- package/dist/components/chart-zoom.js.map +1 -1
- package/dist/components/chip-input.d.ts +0 -4
- package/dist/components/chip-input.js +1 -5
- package/dist/components/chip-input.js.map +1 -1
- package/dist/components/combobox.d.ts +1 -1
- package/dist/components/combobox.js.map +1 -1
- package/dist/components/copy-button.d.ts +5 -1
- package/dist/components/copy-button.js.map +1 -1
- package/dist/components/currency-option-list.d.ts +3 -8
- package/dist/components/currency-option-list.js +1 -29
- package/dist/components/currency-option-list.js.map +1 -1
- package/dist/components/danger-confirm.d.ts +3 -3
- package/dist/components/danger-confirm.js.map +1 -1
- package/dist/components/data-table-filter-popover.d.ts +1 -1
- package/dist/components/data-table-filter-popover.js +1 -1
- package/dist/components/data-table-filter-popover.js.map +1 -1
- package/dist/components/data-table-labels.d.ts +1 -42
- package/dist/components/data-table-labels.js +2 -31
- package/dist/components/data-table-labels.js.map +1 -1
- package/dist/components/data-table-shared.d.ts +17 -1
- package/dist/components/data-table-shared.js +22 -0
- package/dist/components/data-table-shared.js.map +1 -1
- package/dist/components/data-table.d.ts +12 -2
- package/dist/components/data-table.js +18 -14
- package/dist/components/data-table.js.map +1 -1
- package/dist/components/dialog-frame.d.ts +3 -1
- package/dist/components/dialog-frame.js.map +1 -1
- package/dist/components/dropdown.d.ts +18 -15
- package/dist/components/dropdown.js +11 -3
- package/dist/components/dropdown.js.map +1 -1
- package/dist/components/entity-combobox.d.ts +1 -1
- package/dist/components/entity-combobox.js.map +1 -1
- package/dist/components/field-base.d.ts +0 -5
- package/dist/components/field-base.js +0 -5
- package/dist/components/field-base.js.map +1 -1
- package/dist/components/field-parts.d.ts +3 -11
- package/dist/components/field-parts.js +1 -9
- package/dist/components/field-parts.js.map +1 -1
- package/dist/components/field-strip.d.ts +8 -23
- package/dist/components/field-strip.js +24 -22
- package/dist/components/field-strip.js.map +1 -1
- package/dist/components/field-sync.d.ts +0 -4
- package/dist/components/field-sync.js +0 -4
- package/dist/components/field-sync.js.map +1 -1
- package/dist/components/field.d.ts +4 -4
- package/dist/components/field.js.map +1 -1
- package/dist/components/full-bleed-dialog.js +2 -2
- package/dist/components/full-bleed-dialog.js.map +1 -1
- package/dist/components/hover-menu.d.ts +6 -13
- package/dist/components/hover-menu.js +3 -3
- package/dist/components/hover-menu.js.map +1 -1
- package/dist/components/inline-entity-combobox.d.ts +1 -1
- package/dist/components/inline-entity-combobox.js.map +1 -1
- package/dist/components/modal.d.ts +9 -1
- package/dist/components/modal.js +7 -4
- package/dist/components/modal.js.map +1 -1
- package/dist/components/multi-select.d.ts +5 -16
- package/dist/components/multi-select.js +5 -10
- package/dist/components/multi-select.js.map +1 -1
- package/dist/components/number-input.d.ts +0 -3
- package/dist/components/number-input.js +2 -4
- package/dist/components/number-input.js.map +1 -1
- package/dist/components/popover.js +2 -2
- package/dist/components/popover.js.map +1 -1
- package/dist/components/series-chart.js.map +1 -1
- package/dist/components/server-wake.d.ts +0 -6
- package/dist/components/server-wake.js.map +1 -1
- package/dist/components/settings-fields.d.ts +4 -2
- package/dist/components/settings-fields.js.map +1 -1
- package/dist/components/stat-tile.d.ts +8 -2
- package/dist/components/stat-tile.js.map +1 -1
- package/dist/components/swipeable-row.d.ts +2 -17
- package/dist/components/swipeable-row.js +11 -16
- package/dist/components/swipeable-row.js.map +1 -1
- package/dist/components/tooltip.d.ts +9 -1
- package/dist/components/tooltip.js +20 -5
- package/dist/components/tooltip.js.map +1 -1
- package/dist/components/translation-review.d.ts +10 -2
- package/dist/components/translation-review.js +11 -3
- package/dist/components/translation-review.js.map +1 -1
- package/dist/components/use-table-state.d.ts +31 -3
- package/dist/components/use-table-state.js +53 -8
- package/dist/components/use-table-state.js.map +1 -1
- package/dist/components/user-avatar.d.ts +2 -2
- package/dist/components/user-avatar.js.map +1 -1
- package/dist/components/wizard-stepper.d.ts +1 -4
- package/dist/components/wizard-stepper.js +2 -3
- package/dist/components/wizard-stepper.js.map +1 -1
- package/dist/currencies.d.ts +23 -0
- package/dist/currencies.js +38 -0
- package/dist/currencies.js.map +1 -0
- package/dist/demo/demo-banner.d.ts +7 -2
- package/dist/demo/demo-banner.js.map +1 -1
- package/dist/eslint/index.d.ts +83 -0
- package/dist/eslint/index.js +69 -0
- package/dist/eslint/index.js.map +1 -0
- package/dist/eslint/role-utilities.d.ts +48 -0
- package/dist/eslint/role-utilities.generated.d.ts +2 -0
- package/dist/eslint/role-utilities.generated.js +143 -0
- package/dist/eslint/role-utilities.generated.js.map +1 -0
- package/dist/eslint/role-utilities.js +43 -0
- package/dist/eslint/role-utilities.js.map +1 -0
- package/dist/feedback/feedback-menu.d.ts +2 -2
- package/dist/feedback/feedback-menu.js.map +1 -1
- package/dist/feedback/feedback-thread.d.ts +3 -8
- package/dist/feedback/feedback-thread.js +0 -2
- package/dist/feedback/feedback-thread.js.map +1 -1
- package/dist/hooks/use-dismiss.d.ts +16 -3
- package/dist/hooks/use-dismiss.js +14 -2
- package/dist/hooks/use-dismiss.js.map +1 -1
- package/dist/i18n/kit-labels.d.ts +14 -4
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/locales/en.js +1 -1
- package/dist/i18n/locales/en.js.map +2 -2
- package/dist/i18n/locales/fr.js +3 -2
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/it.js +3 -2
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/lib/refs.d.ts +13 -0
- package/dist/lib/refs.js +15 -0
- package/dist/lib/refs.js.map +1 -0
- package/dist/rhf/fields.d.ts +10 -10
- package/dist/rhf/fields.js.map +1 -1
- package/dist/rhf/form-core.d.ts +5 -4
- package/dist/rhf/form-core.js.map +1 -1
- package/dist/rhf/form.d.ts +7 -36
- package/dist/rhf/form.js +1 -16
- package/dist/rhf/form.js.map +1 -1
- package/dist/rhf/line-items.d.ts +1 -1
- package/dist/rhf/line-items.js.map +1 -1
- package/dist/rhf/use-rhf-wizard-step.js.map +1 -1
- package/dist/rhf.js.map +1 -1
- package/dist/shell/option-switcher-menu.d.ts +5 -17
- package/dist/shell/option-switcher-menu.js +1 -3
- package/dist/shell/option-switcher-menu.js.map +1 -1
- package/dist/shell/role-switcher.d.ts +1 -4
- package/dist/shell/role-switcher.js +2 -3
- package/dist/shell/role-switcher.js.map +1 -1
- package/dist/shell/topbar-action-menu.d.ts +7 -19
- package/dist/shell/topbar-action-menu.js +1 -3
- package/dist/shell/topbar-action-menu.js.map +1 -1
- package/dist/shell/topbar-controls.d.ts +3 -12
- package/dist/shell/topbar-controls.js +6 -9
- package/dist/shell/topbar-controls.js.map +1 -1
- package/dist/testing/index.d.ts +1 -1
- package/dist/testing/index.js +2 -0
- package/dist/testing/index.js.map +1 -1
- package/dist/testing/viewport.d.ts +25 -2
- package/dist/testing/viewport.js +61 -10
- package/dist/testing/viewport.js.map +1 -1
- package/dist/theme/palette-presets.d.ts +0 -7
- package/dist/theme/palette-presets.js +0 -279
- package/dist/theme/palette-presets.js.map +1 -1
- package/dist/wizard/types.d.ts +0 -5
- package/dist/wizard/types.js +1 -5
- package/dist/wizard/types.js.map +1 -1
- package/package.json +9 -1
- package/src/billing/billing-banner.tsx +5 -2
- package/src/billing/plan-limit-notice.tsx +5 -2
- package/src/chart.ts +5 -3
- package/src/components/amount-input.tsx +2 -6
- package/src/components/calculator.tsx +2 -7
- package/src/components/chart-zoom.tsx +4 -47
- package/src/components/chip-input.tsx +0 -7
- package/src/components/combobox.tsx +1 -1
- package/src/components/copy-button.tsx +5 -1
- package/src/components/currency-option-list.tsx +8 -42
- package/src/components/danger-confirm.tsx +3 -3
- package/src/components/data-table-filter-popover.tsx +2 -1
- package/src/components/data-table-labels.ts +2 -84
- package/src/components/data-table-shared.tsx +55 -2
- package/src/components/data-table.tsx +29 -17
- package/src/components/dialog-frame.tsx +3 -1
- package/src/components/dropdown.tsx +42 -18
- package/src/components/entity-combobox.tsx +1 -1
- package/src/components/field-base.tsx +0 -11
- package/src/components/field-parts.tsx +5 -19
- package/src/components/field-strip.tsx +9 -25
- package/src/components/field-sync.tsx +0 -7
- package/src/components/field.tsx +4 -4
- package/src/components/full-bleed-dialog.tsx +4 -3
- package/src/components/hover-menu.tsx +12 -16
- package/src/components/inline-entity-combobox.tsx +1 -1
- package/src/components/modal.tsx +20 -4
- package/src/components/multi-select.tsx +13 -30
- package/src/components/number-input.tsx +3 -8
- package/src/components/popover.tsx +5 -3
- package/src/components/series-chart.tsx +1 -1
- package/src/components/server-wake.tsx +0 -6
- package/src/components/settings-fields.tsx +4 -2
- package/src/components/stat-tile.tsx +8 -2
- package/src/components/swipeable-row.tsx +15 -37
- package/src/components/tooltip.tsx +37 -6
- package/src/components/translation-review.tsx +25 -7
- package/src/components/use-table-state.ts +91 -10
- package/src/components/user-avatar.tsx +2 -2
- package/src/components/wizard-stepper.tsx +2 -6
- package/src/currencies.ts +70 -0
- package/src/demo/demo-banner.tsx +7 -2
- package/src/eslint/index.ts +161 -0
- package/src/eslint/role-utilities.generated.ts +146 -0
- package/src/eslint/role-utilities.ts +99 -0
- package/src/feedback/feedback-menu.tsx +2 -2
- package/src/feedback/feedback-thread.tsx +3 -8
- package/src/hooks/use-dismiss.ts +39 -4
- package/src/i18n/kit-labels.tsx +15 -5
- package/src/i18n/locales/fr.ts +3 -2
- package/src/i18n/locales/it.ts +3 -2
- package/src/index.ts +3 -1
- package/src/lib/refs.ts +22 -0
- package/src/rhf/fields.tsx +10 -10
- package/src/rhf/form-core.tsx +5 -4
- package/src/rhf/form.tsx +7 -43
- package/src/rhf/line-items.tsx +1 -1
- package/src/rhf/use-rhf-wizard-step.ts +4 -4
- package/src/rhf.ts +3 -2
- package/src/shell/option-switcher-menu.tsx +5 -22
- package/src/shell/role-switcher.tsx +2 -6
- package/src/shell/topbar-action-menu.tsx +7 -24
- package/src/shell/topbar-controls.tsx +6 -18
- package/src/testing/index.ts +3 -1
- package/src/testing/viewport.ts +125 -19
- package/src/theme/palette-presets.ts +7 -293
- package/src/wizard/types.ts +0 -8
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/components/combobox.tsx"],"sourcesContent":["import { Fragment, useId, useMemo, useRef, useState } from \"react\";\nimport type { ComponentPropsWithoutRef, ReactNode, Ref } from \"react\";\nimport { ChevronDown } from \"lucide-react\";\nimport { FIELD_BASE, FIELD_FLOATING_PAD, FIELD_INVALID } from \"./field-base\";\nimport { cn } from \"../lib/cn\";\nimport { useDropdown } from \"./dropdown\";\nimport { takeEscape, useDismissLayer } from \"../hooks/use-dismiss\";\nimport { isImeEnter } from \"../lib/ime\";\nimport { usePhoneLayout } from \"../hooks/use-breakpoint\";\nimport { PickerSheet, SHEET_ROW_CLASS } from \"./picker-sheet\";\nimport { ComboboxFieldLabel, endHintRowProps, useActiveOptionScroll } from \"./combobox-core\";\nimport { useKitLocale } from \"../i18n/kit-labels\";\nimport { rankMatch } from \"../lib/text-match\";\nimport {\n EndHintRow,\n FieldCaption,\n LABEL_IN_ROW,\n LockedReason,\n mergeDescribedBy,\n StaticLabelRow,\n useFieldError,\n useFieldHint,\n useLockReason,\n type DisabledReasonDisplay,\n} from \"./field-parts\";\nimport type { CommitScope } from \"./write-lock\";\nimport { lockedKeyDown, rowClass, SuggestionList, usePrimaryPressOnly } from \"./combobox-suggestions\";\n// The id-keyed sibling lives in inline-entity-combobox.tsx (MOD-9), the list both share\n// in combobox-suggestions.tsx; its public names are still this module's.\nexport { InlineEntityCombobox } from \"./inline-entity-combobox\";\nexport type { InlineEntityComboboxProps } from \"./inline-entity-combobox\";\n\n/**\n * Four of the div's own attributes are omitted because this component already owns\n * the name, with a different meaning: `id` is the INPUT's (a caller labels or\n * automates the field, not the box around it), and `onChange`/`onBlur`/`onSubmit`\n * are the field's — a value, a departure, and \"I mean what I typed\" — rather than\n * the DOM events of the wrapper. Everything else a wrapper needs reaches the root.\n */\nexport interface ComboboxProps\n extends Omit<ComponentPropsWithoutRef<\"div\">, \"id\" | \"onChange\" | \"onBlur\" | \"onSubmit\"> {\n value: string;\n onChange: (v: string) => void;\n /** Suggestion pool (e.g. existing payee names). */\n options: string[];\n /** The visible label, a `<label for>` above the field: a ReactNode, as on every kit\n * field (a string until 0.34, refactor audit 2026-10, API-9). Without one, name the\n * field with `aria-label`. */\n label?: ReactNode;\n id?: string;\n placeholder?: string;\n className?: string;\n /** Required and unanswered — see {@link Input}'s `invalid`. Set on the `<input>`\n * itself rather than on the wrapper, which is what let Keksdose's account picker\n * delete the `[&_input]:…` copy of {@link FIELD_INVALID} it had been carrying\n * because this component had no `invalid` of its own. */\n invalid?: boolean;\n /** What is wrong with the value, as {@link Input}'s `error`: rendered under the\n * field, on the `<input>`'s `aria-describedby`, and implies `invalid` (lenkbank's\n * scope fields, which moved here off `<Input list>` + `<datalist>`). */\n error?: ReactNode;\n /**\n * Standing advice for the field — {@link Input}'s `hint`, so the combobox family\n * reads like every other field (keksdose K4: the family had `error` and no `hint`, so\n * the app's pickers carried their captions outside the component, attached to\n * nothing). Plain TEXT is a caption under the field on the input's\n * `aria-describedby`, before any error; a {@link FieldHint} \"?\" rides the label line,\n * or with no label sits at the field's end edge, outside the box.\n */\n hint?: ReactNode;\n /** As `<input disabled>`: no focus, no list, the field's settled look. The chevron\n * stops toggling with it — it is a mouse target the input's own `disabled` does\n * not reach. */\n disabled?: boolean;\n /**\n * Why the value cannot be changed — {@link Button}'s `disabledReason`, for a field\n * that SAVES itself (an inline editor whose blur writes). The input stays focusable\n * and `aria-disabled`, is `readOnly`, opens no list and calls neither `onChange` nor\n * `onSubmit`; the reason is in the kit {@link Tooltip} and on its `aria-describedby`.\n * Wins over `disabled`. The family's lock (keksdose G2) — see {@link InlineEntityCombobox}.\n */\n disabledReason?: ReactNode;\n /** This field COMMITS. Under a locked {@link WriteLockProvider} it is locked the\n * `disabledReason` way with the lock's reason; inside a form with its own Save, leave\n * it off and put `commit` on the Save. No provider, or an unlocked one: no effect. */\n commit?: CommitScope;\n /** Where the reason shows — {@link Button}'s `disabledReasonDisplay`: a line under the\n * field at Large and on a touch screen by default, the tooltip otherwise (DUP-3). */\n disabledReasonDisplay?: DisabledReasonDisplay;\n /** Heading an option belongs under. Supplying it makes this list read exactly\n * like {@link InlineEntityCombobox}'s — one heading per group with its rows\n * indented beneath — instead of a flat list (feedback #136 rework: the payee\n * field sat next to the newly-grouped category field and no longer matched).\n * Omit for a plain list. */\n groupBy?: (option: string) => string;\n /**\n * Something to show at the far end of an option's row — a badge saying what the\n * option IS, as distinct from what it is called.\n *\n * Keksdose's category-name field is the case: its proposals carry per-locale\n * names, so picking one stores a category that follows the UI language, and\n * picking a look-alike custom name does not. That difference is invisible in the\n * label and decides what the row DOES, so the row has to show it. Returning\n * `null` for an option renders nothing and costs no layout.\n *\n * Not rendered in the create row, which by definition names nothing in the pool.\n */\n optionAdornment?: (option: string) => ReactNode;\n /** Focus on mount, the way `<input autoFocus>` does — and, through `onFocus`, open\n * the list with it. A click-the-value inline editor needs it: the cell the user\n * clicked names the field the caret should land in. {@link InlineEntityCombobox}\n * has had it since live #218. */\n autoFocus?: boolean;\n /** Focus genuinely LEFT the field. Not fired by picking a row — the rows suppress\n * `mousedown`, so the input never blurs — which is what makes it usable as a\n * \"close the inline editor\" signal. */\n onBlur?: () => void;\n /** Enter pressed with no row highlighted, i.e. \"I mean what I typed\". The list\n * closes either way; this is for a caller whose Enter also submits a row editor. */\n onSubmit?: () => void;\n /** Rows to offer when the field is empty. 8 on a dropdown, where that is all\n * that fits; the phone sheet asks for more because it has a screen. */\n maxSuggestions?: number;\n searchPlaceholder?: string;\n closeLabel?: string;\n /**\n * The phone sheet's heading — and, as a string, the dialog's accessible name — for a\n * field whose label is drawn by someone else: a form's `FormLabel` above it,\n * which `RhfTextCombobox` hands over here (0.24). Default: `label`. Without either, the\n * full-screen sheet opened untitled and unnamed, the one screen on a phone that has to\n * say what it is asking for (live #200).\n */\n sheetTitle?: ReactNode;\n /**\n * Label for the row that COMMITS a value the list does not contain — e.g.\n * `(v) => \\`Add \"${v}\" as payee\\``. Supplying it turns the free text into\n * something you confirm rather than something you leave behind.\n *\n * Keksdose live #212: *\"For the payee input have an apply button, when input is\n * placed like 'add xxx as payee' to confirm. Now it can be put in and closed with\n * the right top X, which seems not intuitive.\"* On the phone sheet the search box\n * IS the value, so typing a brand-new name and dismissing the sheet did commit it —\n * but the only control on offer was the close X, which reads as \"discard\". A named\n * affordance says what the typing did.\n *\n * Omit and the row is not rendered, which is the right default for a list whose\n * values are all supposed to already exist.\n */\n createLabel?: (value: string) => string;\n /**\n * The `<input>` — the control a reader types in and the one focus belongs on — as a\n * React 19 ref prop, the way {@link CountrySelect} takes its trigger's (0.24, kastlan:\n * react-hook-form's focus-on-error calls `focus()` on whatever `field.ref` is handed,\n * and a combobox with no ref inside a hand-wired `RhfField` gave it nothing, so a\n * failed submit could not take the user to it). Not the wrapper: a ref is for focus,\n * measurement and selection, and all three are the input's. `RhfTextCombobox` passes it.\n */\n ref?: Ref<HTMLInputElement>;\n}\n\n/**\n * Free-text combobox that looks like the shared {@link Select} (same FIELD_BASE\n * styling, chevron and static floating label) but lets the user type a value\n * that isn't in the list — e.g. naming a brand-new payee. Picking from the\n * filtered suggestion list fills the value; typing keeps whatever was entered.\n *\n * Use this instead of an `<Input list=\"…\">` + `<datalist>` so the control is\n * visually and behaviourally consistent with the other dropdowns (feedback\n * #230 — the payee field looked/behaved differently from every other select).\n */\nexport function Combobox(props: ComboboxProps) {\n const {\n value,\n onChange,\n options,\n label,\n id,\n placeholder,\n className,\n groupBy,\n maxSuggestions,\n searchPlaceholder,\n closeLabel,\n sheetTitle,\n createLabel,\n invalid,\n error,\n hint,\n disabled,\n disabledReason,\n disabledReasonDisplay,\n commit,\n optionAdornment,\n autoFocus,\n onBlur,\n onSubmit,\n ref,\n \"aria-label\": ariaLabel,\n // Off `rest` and onto the <input>, which is the combobox a reader meets: `Field`'s\n // render-prop spreads `{ id, aria-describedby, aria-invalid, aria-required }`, and\n // on the wrapper div the hint and required state described nothing.\n \"aria-describedby\": ariaDescribedBy,\n \"aria-invalid\": ariaInvalid,\n \"aria-required\": ariaRequired,\n ...rest\n } = props;\n const hintParts = useFieldHint(hint, ariaDescribedBy);\n const field = useFieldError(error, invalid, hintParts.describedBy, ariaInvalid);\n const generated = useId();\n const fieldId = id ?? generated;\n // Derived from the GENERATED id, never from `id`: a caller's id is theirs to\n // collide with, and `aria-controls` has to resolve.\n const listboxId = `${generated}-listbox`;\n const optionId = (index: number) => `${generated}-option-${index}`;\n // `backCloses` is the desktop half of live #309: the phone's list IS a\n // {@link PickerSheet}, which registers its own history entry, so registering a\n // second one here would cost two Back presses to dismiss one sheet. Its Escape\n // layer is this field's own, below — the list can be open with nothing to show.\n const isPhone = usePhoneLayout();\n const { open, setOpen, wrapperRef, panelRef } = useDropdown({ backCloses: !isPhone, escapeLayer: false });\n const primaryOnly = usePrimaryPressOnly();\n const [active, setActive] = useState(-1);\n // The lock (see `lockedKeyDown`): a lock arriving while the list is up closes it,\n // adjusted while rendering so no frame offers a row that can no longer be taken.\n const lock = useLockReason(commit, disabledReason);\n const locked = lock.locked;\n const inert = locked || Boolean(disabled);\n if (locked && open) setOpen(false);\n // A phone opens the list as a full-screen sheet with its own input, the way a\n // native <select> does — live #200: \"Paid as full screen dialog with input.\n // Similar to the account select that already appears as full screen.\" The\n // anchored list stays for pointer devices, where it is the better shape.\n const sheetInputRef = useRef<HTMLInputElement | null>(null);\n // The box the portalled list hangs off — the inner wrapper, which hugs the input,\n // not the outer one (a grid item that can be taller than the field).\n const fieldRef = useRef<HTMLDivElement>(null);\n\n /**\n * Is the text in the field a QUERY, or just what was picked last time?\n *\n * Keksdose dev#549, filed against the category-name field: *\"Currently it acts kind\n * of a filter and clicking again does not open anything since the previous text is\n * still there likely filtering all other options out.\"* A combobox whose filter is\n * its own value can only ever re-offer the answer it already has — after picking\n * \"REWE\" the list reopens holding one row, the one you are looking at, and changing\n * your mind means clearing the field by hand first.\n *\n * So the text filters only while it is being TYPED; opening the list, by focus or\n * by clicking the field again, puts the whole pool back. {@link InlineEntityCombobox}\n * below has had this since it was written (see its `typedQuery`) and the app's\n * category-name field grew its own copy to get it — this one, the payee field, was\n * the one left with the bug.\n *\n * Not derived from \"does the text exactly match an option\": a custom value that\n * happens to collide with one would then behave differently from every other, and\n * what is being tracked is what the user just did, which is not a property of the\n * string.\n */\n const [typing, setTyping] = useState(false);\n\n const query = typing ? value.trim() : \"\";\n // Folded as every kit list folds (DUP-5): \"zurich\" finds Zürich, by the locale's case.\n const locale = useKitLocale();\n const matches = useMemo(() => {\n const seen = new Set<string>();\n const uniq = options.filter((o) => o && !seen.has(o) && seen.add(o));\n let ranked: string[];\n const cap = maxSuggestions ?? (isPhone ? 50 : 8);\n if (!query) {\n ranked = uniq.slice(0, cap);\n } else {\n // The whole name, then prefix matches, then substring matches — most relevant on\n // top (`rankMatch`; a stable sort keeps the pool's order within a rank).\n ranked = uniq\n .map((o) => ({ o, rank: rankMatch(o, query, { locale }) }))\n .filter((x) => x.rank >= 0)\n .sort((a, b) => a.rank - b.rank)\n .map((x) => x.o)\n .slice(0, cap);\n }\n if (!groupBy) return ranked;\n // Keep each group contiguous so a heading appears once instead of every time\n // the ranking interleaves two groups — while preserving \"best match first\":\n // a Map keeps insertion order, so groups come out ordered by their best-ranked\n // member and members keep their rank order inside the group.\n const byGroup = new Map<string, string[]>();\n for (const o of ranked) {\n const key = groupBy(o);\n if (!byGroup.has(key)) byGroup.set(key, []);\n byGroup.get(key)!.push(o);\n }\n return [...byGroup.values()].flat();\n }, [options, query, groupBy, maxSuggestions, isPhone, locale]);\n\n /**\n * Which rows OPEN a group, and with what heading — computed once for both lists.\n *\n * The desktop list had this as `matches[i - 1]`, which is free; the sheet had it as\n * `matches[matches.indexOf(o) - 1]`, which is a scan of the list per row and another\n * per heading test. That is quadratic in the sheet's own cap of 50, on the branch\n * that runs on the slowest hardware we ship to — and it answers the wrong question\n * besides: `indexOf` finds the FIRST row with that string, so a duplicated option\n * would have compared against a neighbour it is not next to. (`matches` de-duplicates\n * today; that is a property of the memo above, not of the row being rendered.)\n */\n const groupHeadings = useMemo(() => {\n if (!groupBy) return null;\n const heads = new Map<number, string>();\n let prev: string | undefined;\n matches.forEach((o, i) => {\n const group = groupBy(o);\n if (group != null && group !== prev) heads.set(i, group);\n prev = group;\n });\n return heads;\n }, [matches, groupBy]);\n\n const take = (v: string) => {\n if (locked) return;\n onChange(v);\n setOpen(false);\n setActive(-1);\n // What is in the field is now an ANSWER, not a query — so reopening offers the\n // whole pool again rather than a one-row filter of the value just chosen.\n setTyping(false);\n };\n\n // Offered only when the typed text is genuinely NOT in the pool: an exact\n // (case-insensitive) match is an existing entry the list is already showing, and a\n // second way to pick it would be noise. Compared against `options`, not `matches` —\n // `matches` is capped, so a pool of 300 payees would otherwise offer to \"add\" one\n // that exists but fell off the end of the list.\n // What the field claims the keyboard is on. Only on a pointer device: the phone\n // renders its rows in a {@link PickerSheet} whose own search box owns focus, and a\n // field pointing at an id that is not there is worse than one pointing nowhere.\n const activeId = !isPhone && active >= 0 && active < matches.length ? optionId(active) : undefined;\n useActiveOptionScroll(activeId);\n\n // While locked the field sits in the reason's Tooltip — in a fragment with a hidden\n // copy of the reason, Button's anatomy; `block` so a full-width field stays full width.\n const withLock = (box: ReactNode) =>\n locked ? (\n <LockedReason lock={lock} display={disabledReasonDisplay} className=\"block\">\n {box}\n </LockedReason>\n ) : (\n box\n );\n\n const typed = value.trim();\n const createRow =\n createLabel && typed.length > 0 && !options.some((o) => o.toLowerCase() === typed.toLowerCase())\n ? createLabel(typed)\n : null;\n\n // The list is a layer on the Escape stack while it SHOWS (Wave 2, DUP-1): an Escape\n // over an empty \"open\" list is the dialog's, as it always was. On a phone the sheet\n // is the layer.\n const showing = !isPhone && open && !inert && (matches.length > 0 || Boolean(createRow));\n useDismissLayer(showing, () => {\n setOpen(false);\n setActive(-1);\n });\n\n return (\n // `rest` dresses the outer box — a `data-tour` anchor, a test id. Not the NAME,\n // description, invalid or required state: those belong on the <input> below,\n // which is the combobox a reader meets. Spread FIRST, so the field's ARIA and the\n // handlers carrying live #309 and dev#549 cannot be replaced from outside.\n <div {...rest} ref={wrapperRef} className={cn(\"relative\", className)}>\n {/* A real <label for> since lenkbank's tests met a field `getByLabelText` could\n not find: it used to be a <span>, with the text copied onto `aria-label`. The\n name a reader hears is the same words either way. With a \"?\" it moves into a\n row after the field, below. */}\n {label !== undefined && hintParts.labelHint === undefined && (\n <ComboboxFieldLabel htmlFor={fieldId} className={inert ? \"opacity-50\" : undefined}>\n {label}\n </ComboboxFieldLabel>\n )}\n <EndHintRow {...endHintRowProps(\"hint\" in props, label !== undefined, hintParts.labelHint)}>\n {withLock(\n /* The chevron centers against this inner wrapper, which hugs the input.\n The outer div can be taller than the input (as a grid item it\n stretches to the row height, e.g. next to the editor's category cell\n with its split button), which used to drag a top-1/2 chevron down to\n the input's bottom edge (feedback #248).\n Dimmed as a whole when disabled (or locked), the way EntityCombobox dims its\n trigger. FIELD_BASE's grey alone left a disabled picker looking like a\n filled-in one beside the pickers that do dim (the label is not the input's\n `peer`, so it is dimmed by hand above). */\n <div ref={fieldRef} className={cn(\"relative\", inert && \"cursor-not-allowed opacity-50\")}>\n <input\n ref={ref}\n id={fieldId}\n value={value}\n placeholder={placeholder}\n // The caller's name, over the <label for> above — which is what names the\n // field otherwise (dev#477: a field with neither announces only its text).\n aria-label={ariaLabel}\n role=\"combobox\"\n aria-expanded={open}\n // The list this field is the mouth of. Required by the role, and the half\n // that was missing: the field said it was expanded and never said what it\n // had expanded, so a reader had no way from the box to the options\n // (ESLint's `role-has-required-aria-props`, the audit's §a11y).\n aria-controls={listboxId}\n aria-activedescendant={activeId}\n aria-autocomplete=\"list\"\n aria-invalid={field.isInvalid || undefined}\n aria-describedby={mergeDescribedBy(field.describedBy, locked && lock.reasonId)}\n aria-required={ariaRequired}\n autoComplete=\"off\"\n // Locked: focusable, `readOnly`, `aria-disabled` — see `lockedKeyDown`.\n disabled={locked ? undefined : disabled}\n readOnly={locked || undefined}\n aria-disabled={locked || undefined}\n // 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.\n autoFocus={autoFocus}\n onBlur={onBlur}\n onMouseDown={primaryOnly.onMouseDown}\n onFocus={() => {\n // A back/forward mouse button focuses this field on its way to\n // navigating; it is not a request to open anything (live #309 rework).\n // A locked field takes focus to say why, and opens nothing.\n if (primaryOnly.fromAuxButton() || locked) return;\n setOpen(true);\n setTyping(false);\n // The sheet carries its own input, so the field behind it must not also\n // pull up the keyboard and scroll the page under the dialog.\n if (isPhone) sheetInputRef.current?.focus();\n }}\n // A CLICK as well as focus — the other half of dev#549. Picking a suggestion\n // closes the list without moving focus (the rows suppress `mousedown` on\n // purpose, so the input never blurred), which means clicking the field again\n // fires no `focus` event at all and the list stayed shut. \"Does not open\n // anything\" was literally true.\n onClick={() => {\n if (locked) return;\n setOpen(true);\n setTyping(false);\n }}\n // `inputMode=\"none\"` rather than readOnly: the field must not look\n // uneditable (FIELD_BASE greys a read-only field since dev#468) and must\n // still take focus — it just has no keyboard of its own, the same trick\n // the amount field uses for the numpad.\n inputMode={isPhone ? \"none\" : undefined}\n onChange={(e) => {\n if (locked) return;\n onChange(e.target.value);\n setOpen(true);\n setActive(-1);\n setTyping(true);\n }}\n onKeyDown={(e) => {\n if (locked) return lockedKeyDown(e);\n if (e.key === \"ArrowDown\") {\n e.preventDefault();\n setOpen(true);\n setActive((i) => Math.min(i + 1, matches.length - 1));\n } else if (e.key === \"ArrowUp\") {\n e.preventDefault();\n setActive((i) => Math.max(i - 1, 0));\n } else if (e.key === \"Enter\") {\n // The Enter that confirms an IME composition is the IME's: it picks no\n // row and submits nothing (BUG-9).\n if (isImeEnter(e)) return;\n if (open && active >= 0 && active < matches.length) {\n e.preventDefault();\n take(matches[active]);\n } else {\n // No row highlighted: the typed text is the answer. `preventDefault`\n // only when a caller is taking Enter, so a plain form submit is\n // otherwise left alone.\n if (onSubmit) e.preventDefault();\n setOpen(false);\n onSubmit?.();\n }\n } else if (e.key === \"Escape\") {\n // A list that is SHOWING is the top of the Escape stack, and the key\n // closes it and stops here: a Modal or FullBleedDialog around the field\n // otherwise closed with it, form and all (round 0.34 BUG-1). With no\n // list on screen the key goes on to whoever wants it.\n if (showing && takeEscape(e)) return;\n setOpen(false);\n setActive(-1);\n } else if (e.key === \"Tab\") {\n // Closes, and lets the browser take the Tab: focus is in THIS input,\n // which is staying, so there is nothing to catch — unlike the pickers\n // whose focus sits inside a portalled panel.\n setOpen(false);\n setActive(-1);\n }\n // Home/End are deliberately absent. The APG gives them to the list only\n // where the combobox is not editable; here the text IS the value, a\n // payee name is long enough to want the caret moved to its start, and\n // the desktop list is capped at 8 rows — so jumping it would be worth\n // almost nothing and would cost the one gesture that field is used with.\n }}\n className={cn(\n FIELD_BASE,\n label !== undefined && FIELD_FLOATING_PAD,\n \"pe-9\",\n field.isInvalid && FIELD_INVALID,\n )}\n />\n <ChevronDown\n aria-hidden\n onMouseDown={(e) => {\n // Toggle on the chevron without stealing focus from the input.\n e.preventDefault();\n if (!inert) setOpen((o) => !o);\n }}\n className={cn(\n \"absolute end-2.5 top-1/2 size-4 -translate-y-1/2 text-placeholder\",\n // The box above does the dimming; dimming here too would halve it again.\n !inert && \"cursor-pointer\",\n )}\n />\n </div>,\n )}\n </EndHintRow>\n {/* The label and its \"?\" share the top strip — after the field in the DOM, so the\n \"?\" follows the control in the tab order, as it does on Input and Select. */}\n {label !== undefined && hintParts.labelHint !== undefined && (\n <StaticLabelRow hint={hintParts.labelHint}>\n <ComboboxFieldLabel htmlFor={fieldId} className={cn(LABEL_IN_ROW, inert && \"opacity-50\")}>\n {label}\n </ComboboxFieldLabel>\n </StaticLabelRow>\n )}\n <FieldCaption parts={hintParts} />\n {field.errorEl}\n {isPhone && (\n <PickerSheet\n open={open && !inert}\n onClose={() => setOpen(false)}\n title={sheetTitle ?? label}\n // The sheet's input IS the field: this is a free-text control, so what is\n // typed here is the value (a brand-new payee is just a name nothing\n // matches), and the list below narrows as it changes.\n query={value}\n onQueryChange={(v) => {\n onChange(v);\n setTyping(true);\n }}\n searchPlaceholder={searchPlaceholder ?? placeholder}\n inputRef={sheetInputRef}\n closeLabel={closeLabel}\n >\n <ul id={listboxId} role=\"listbox\">\n {createRow && (\n // `role=\"option\"` rides the BUTTON, not the <li> around it: an option\n // may not contain a separately focusable control, and the button is\n // what a pointer presses. Same shape `CommandPalette` has always had.\n <li role=\"presentation\">\n <button\n type=\"button\"\n role=\"option\"\n aria-selected={false}\n onClick={() => take(typed)}\n className={cn(SHEET_ROW_CLASS, \"font-medium text-brand\")}\n >\n {createRow}\n </button>\n </li>\n )}\n {matches.map((o, i) => {\n const group = groupHeadings?.get(i);\n return (\n <Fragment key={o}>\n {group !== undefined && (\n <li\n role=\"presentation\"\n className=\"px-4 pb-0.5 pt-3 text-caption font-semibold uppercase tracking-wide text-muted\"\n >\n {group}\n </li>\n )}\n <li role=\"presentation\">\n <button\n type=\"button\"\n role=\"option\"\n aria-selected={o === value}\n onClick={() => take(o)}\n className={cn(\n SHEET_ROW_CLASS,\n optionAdornment && \"flex items-center justify-between gap-2\",\n )}\n >\n {optionAdornment ? <span className=\"truncate-until-large\">{o}</span> : o}\n {optionAdornment?.(o)}\n </button>\n </li>\n </Fragment>\n );\n })}\n </ul>\n </PickerSheet>\n )}\n {showing && (\n <SuggestionList id={listboxId} anchorRef={fieldRef} panelRef={panelRef}>\n {createRow && (\n <li role=\"presentation\">\n <button\n type=\"button\"\n role=\"option\"\n aria-selected={false}\n tabIndex={-1}\n onMouseDown={(e) => {\n // mousedown, like the rows below: the input's blur would otherwise\n // close the list before the click landed.\n e.preventDefault();\n take(typed);\n }}\n className={cn(rowClass(false), \"font-medium text-brand\")}\n >\n {createRow}\n </button>\n </li>\n )}\n {matches.map((o, i) => {\n // Heading at each group boundary only — `matches` is group-contiguous, so\n // the boundaries are a property of the list rather than of the branch\n // drawing it. See `groupHeadings`.\n const group = groupHeadings?.get(i);\n return (\n <Fragment key={o}>\n {group !== undefined && (\n <li\n role=\"presentation\"\n className=\"px-3 pb-0.5 pt-2 text-caption font-semibold uppercase tracking-wide text-muted first:pt-1\"\n >\n {group}\n </li>\n )}\n <li role=\"presentation\">\n <button\n type=\"button\"\n id={optionId(i)}\n role=\"option\"\n // CHOSEN, not highlighted. The row the arrows are on is named by\n // the field's `aria-activedescendant`; saying \"selected\" for it\n // would tell a reader that the row they are passing over is\n // already the answer.\n aria-selected={o === value}\n // Focus stays in the field — that is what\n // `aria-activedescendant` is for — so the rows are not tab stops.\n tabIndex={-1}\n onMouseDown={(e) => {\n // mousedown (not click) so the blur from the input firing first\n // doesn't close the list before the selection registers.\n e.preventDefault();\n take(o);\n }}\n onMouseEnter={() => setActive(i)}\n className={cn(\n rowClass(i === active),\n group != null && \"ps-6\",\n // A row with a badge is a flex row so the label truncates and the\n // badge keeps its width; without one it stays the plain block the\n // other lists render, so nothing shifts for callers that pass none.\n // From Large up the label wraps instead (`truncate-until-large`,\n // text size §10.17, round 0.34 T1), as the badge-less row does.\n optionAdornment && \"flex items-center justify-between gap-2\",\n )}\n >\n {optionAdornment ? <span className=\"truncate-until-large\">{o}</span> : o}\n {optionAdornment?.(o)}\n </button>\n </li>\n </Fragment>\n );\n })}\n </SuggestionList>\n )}\n </div>\n );\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/components/combobox.tsx"],"sourcesContent":["import { Fragment, useId, useMemo, useRef, useState } from \"react\";\nimport type { ComponentPropsWithoutRef, ReactNode, Ref } from \"react\";\nimport { ChevronDown } from \"lucide-react\";\nimport { FIELD_BASE, FIELD_FLOATING_PAD, FIELD_INVALID } from \"./field-base\";\nimport { cn } from \"../lib/cn\";\nimport { useDropdown } from \"./dropdown\";\nimport { takeEscape, useDismissLayer } from \"../hooks/use-dismiss\";\nimport { isImeEnter } from \"../lib/ime\";\nimport { usePhoneLayout } from \"../hooks/use-breakpoint\";\nimport { PickerSheet, SHEET_ROW_CLASS } from \"./picker-sheet\";\nimport { ComboboxFieldLabel, endHintRowProps, useActiveOptionScroll } from \"./combobox-core\";\nimport { useKitLocale } from \"../i18n/kit-labels\";\nimport { rankMatch } from \"../lib/text-match\";\nimport {\n EndHintRow,\n FieldCaption,\n LABEL_IN_ROW,\n LockedReason,\n mergeDescribedBy,\n StaticLabelRow,\n useFieldError,\n useFieldHint,\n useLockReason,\n type DisabledReasonDisplay,\n} from \"./field-parts\";\nimport type { CommitScope } from \"./write-lock\";\nimport { lockedKeyDown, rowClass, SuggestionList, usePrimaryPressOnly } from \"./combobox-suggestions\";\n// The id-keyed sibling lives in inline-entity-combobox.tsx (MOD-9), the list both share\n// in combobox-suggestions.tsx; its public names are still this module's.\nexport { InlineEntityCombobox } from \"./inline-entity-combobox\";\nexport type { InlineEntityComboboxProps } from \"./inline-entity-combobox\";\n\n/**\n * Four of the div's own attributes are omitted because this component already owns\n * the name, with a different meaning: `id` is the INPUT's (a caller labels or\n * automates the field, not the box around it), and `onChange`/`onBlur`/`onSubmit`\n * are the field's — a value, a departure, and \"I mean what I typed\" — rather than\n * the DOM events of the wrapper. Everything else a wrapper needs reaches the root.\n */\nexport interface ComboboxProps\n extends Omit<ComponentPropsWithoutRef<\"div\">, \"id\" | \"onChange\" | \"onBlur\" | \"onSubmit\"> {\n value: string;\n onChange: (v: string) => void;\n /** Suggestion pool (e.g. existing payee names). */\n options: string[];\n /** The visible label, a `<label for>` above the field: a ReactNode, as on every kit\n * field (a string until 0.34, refactor audit 2026-10, API-9). Without one, name the\n * field with `aria-label`. */\n label?: ReactNode;\n id?: string;\n placeholder?: string;\n className?: string;\n /** Required and unanswered — see {@link Input}'s `invalid`. Set on the `<input>`\n * itself rather than on the wrapper, which is what let Keksdose's account picker\n * delete the `[&_input]:…` copy of {@link FIELD_INVALID} it had been carrying\n * because this component had no `invalid` of its own. */\n invalid?: boolean;\n /** What is wrong with the value, as {@link Input}'s `error`: rendered under the\n * field, on the `<input>`'s `aria-describedby`, and implies `invalid` (lenkbank's\n * scope fields, which moved here off `<Input list>` + `<datalist>`). */\n error?: ReactNode;\n /**\n * Standing advice for the field — {@link Input}'s `hint`, so the combobox family\n * reads like every other field (keksdose K4: the family had `error` and no `hint`, so\n * the app's pickers carried their captions outside the component, attached to\n * nothing). Plain TEXT is a caption under the field on the input's\n * `aria-describedby`, before any error; a {@link FieldHint} \"?\" rides the label line,\n * or with no label sits at the field's end edge, outside the box.\n */\n hint?: ReactNode;\n /** As `<input disabled>`: no focus, no list, the field's settled look. The chevron\n * stops toggling with it — it is a mouse target the input's own `disabled` does\n * not reach. */\n disabled?: boolean;\n /**\n * Why the value cannot be changed — {@link Button}'s `disabledReason`, for a field\n * that SAVES itself (an inline editor whose blur writes). The input stays focusable\n * and `aria-disabled`, is `readOnly`, opens no list and calls neither `onChange` nor\n * `onSubmit`; the reason is in the kit {@link Tooltip} and on its `aria-describedby`.\n * Wins over `disabled`. The family's lock (keksdose G2) — see {@link InlineEntityCombobox}.\n */\n disabledReason?: ReactNode;\n /** This field COMMITS. Under a locked {@link WriteLockProvider} it is locked the\n * `disabledReason` way with the lock's reason; inside a form with its own Save, leave\n * it off and put `commit` on the Save. No provider, or an unlocked one: no effect. */\n commit?: CommitScope;\n /** Where the reason shows — {@link Button}'s `disabledReasonDisplay`: a line under the\n * field at Large and on a touch screen by default, the tooltip otherwise (DUP-3). */\n disabledReasonDisplay?: DisabledReasonDisplay;\n /** Heading an option belongs under. Supplying it makes this list read exactly\n * like {@link InlineEntityCombobox}'s — one heading per group with its rows\n * indented beneath — instead of a flat list (feedback #136 rework: the payee\n * field sat next to the newly-grouped category field and no longer matched).\n * Omit for a plain list. */\n groupBy?: (option: string) => string;\n /**\n * Something to show at the far end of an option's row — a badge saying what the\n * option IS, as distinct from what it is called.\n *\n * Keksdose's category-name field is the case: its proposals carry per-locale\n * names, so picking one stores a category that follows the UI language, and\n * picking a look-alike custom name does not. That difference is invisible in the\n * label and decides what the row DOES, so the row has to show it. Returning\n * `null` for an option renders nothing and costs no layout.\n *\n * Not rendered in the create row, which by definition names nothing in the pool.\n */\n optionAdornment?: (option: string) => ReactNode;\n /** Focus on mount, the way `<input autoFocus>` does — and, through `onFocus`, open\n * the list with it. A click-the-value inline editor needs it: the cell the user\n * clicked names the field the caret should land in. {@link InlineEntityCombobox}\n * has had it since live #218. */\n autoFocus?: boolean;\n /** Focus genuinely LEFT the field. Not fired by picking a row — the rows suppress\n * `mousedown`, so the input never blurs — which is what makes it usable as a\n * \"close the inline editor\" signal. */\n onBlur?: () => void;\n /** Enter pressed with no row highlighted, i.e. \"I mean what I typed\". The list\n * closes either way; this is for a caller whose Enter also submits a row editor. */\n onSubmit?: () => void;\n /** Rows to offer when the field is empty. 8 on a dropdown, where that is all\n * that fits; the phone sheet asks for more because it has a screen. */\n maxSuggestions?: number;\n searchPlaceholder?: string;\n closeLabel?: string;\n /**\n * The phone sheet's heading — and, as a string, the dialog's accessible name — for a\n * field whose label is drawn by someone else: a bound field's label above it,\n * which `RhfTextCombobox` hands over here (0.24). Default: `label`. Without either, the\n * full-screen sheet opened untitled and unnamed, the one screen on a phone that has to\n * say what it is asking for (live #200).\n */\n sheetTitle?: ReactNode;\n /**\n * Label for the row that COMMITS a value the list does not contain — e.g.\n * `(v) => \\`Add \"${v}\" as payee\\``. Supplying it turns the free text into\n * something you confirm rather than something you leave behind.\n *\n * Keksdose live #212: *\"For the payee input have an apply button, when input is\n * placed like 'add xxx as payee' to confirm. Now it can be put in and closed with\n * the right top X, which seems not intuitive.\"* On the phone sheet the search box\n * IS the value, so typing a brand-new name and dismissing the sheet did commit it —\n * but the only control on offer was the close X, which reads as \"discard\". A named\n * affordance says what the typing did.\n *\n * Omit and the row is not rendered, which is the right default for a list whose\n * values are all supposed to already exist.\n */\n createLabel?: (value: string) => string;\n /**\n * The `<input>` — the control a reader types in and the one focus belongs on — as a\n * React 19 ref prop, the way {@link CountrySelect} takes its trigger's (0.24, kastlan:\n * react-hook-form's focus-on-error calls `focus()` on whatever `field.ref` is handed,\n * and a combobox with no ref inside a hand-wired `RhfField` gave it nothing, so a\n * failed submit could not take the user to it). Not the wrapper: a ref is for focus,\n * measurement and selection, and all three are the input's. `RhfTextCombobox` passes it.\n */\n ref?: Ref<HTMLInputElement>;\n}\n\n/**\n * Free-text combobox that looks like the shared {@link Select} (same FIELD_BASE\n * styling, chevron and static floating label) but lets the user type a value\n * that isn't in the list — e.g. naming a brand-new payee. Picking from the\n * filtered suggestion list fills the value; typing keeps whatever was entered.\n *\n * Use this instead of an `<Input list=\"…\">` + `<datalist>` so the control is\n * visually and behaviourally consistent with the other dropdowns (feedback\n * #230 — the payee field looked/behaved differently from every other select).\n */\nexport function Combobox(props: ComboboxProps) {\n const {\n value,\n onChange,\n options,\n label,\n id,\n placeholder,\n className,\n groupBy,\n maxSuggestions,\n searchPlaceholder,\n closeLabel,\n sheetTitle,\n createLabel,\n invalid,\n error,\n hint,\n disabled,\n disabledReason,\n disabledReasonDisplay,\n commit,\n optionAdornment,\n autoFocus,\n onBlur,\n onSubmit,\n ref,\n \"aria-label\": ariaLabel,\n // Off `rest` and onto the <input>, which is the combobox a reader meets: `Field`'s\n // render-prop spreads `{ id, aria-describedby, aria-invalid, aria-required }`, and\n // on the wrapper div the hint and required state described nothing.\n \"aria-describedby\": ariaDescribedBy,\n \"aria-invalid\": ariaInvalid,\n \"aria-required\": ariaRequired,\n ...rest\n } = props;\n const hintParts = useFieldHint(hint, ariaDescribedBy);\n const field = useFieldError(error, invalid, hintParts.describedBy, ariaInvalid);\n const generated = useId();\n const fieldId = id ?? generated;\n // Derived from the GENERATED id, never from `id`: a caller's id is theirs to\n // collide with, and `aria-controls` has to resolve.\n const listboxId = `${generated}-listbox`;\n const optionId = (index: number) => `${generated}-option-${index}`;\n // `backCloses` is the desktop half of live #309: the phone's list IS a\n // {@link PickerSheet}, which registers its own history entry, so registering a\n // second one here would cost two Back presses to dismiss one sheet. Its Escape\n // layer is this field's own, below — the list can be open with nothing to show.\n const isPhone = usePhoneLayout();\n const { open, setOpen, wrapperRef, panelRef } = useDropdown({ backCloses: !isPhone, escapeLayer: false });\n const primaryOnly = usePrimaryPressOnly();\n const [active, setActive] = useState(-1);\n // The lock (see `lockedKeyDown`): a lock arriving while the list is up closes it,\n // adjusted while rendering so no frame offers a row that can no longer be taken.\n const lock = useLockReason(commit, disabledReason);\n const locked = lock.locked;\n const inert = locked || Boolean(disabled);\n if (locked && open) setOpen(false);\n // A phone opens the list as a full-screen sheet with its own input, the way a\n // native <select> does — live #200: \"Paid as full screen dialog with input.\n // Similar to the account select that already appears as full screen.\" The\n // anchored list stays for pointer devices, where it is the better shape.\n const sheetInputRef = useRef<HTMLInputElement | null>(null);\n // The box the portalled list hangs off — the inner wrapper, which hugs the input,\n // not the outer one (a grid item that can be taller than the field).\n const fieldRef = useRef<HTMLDivElement>(null);\n\n /**\n * Is the text in the field a QUERY, or just what was picked last time?\n *\n * Keksdose dev#549, filed against the category-name field: *\"Currently it acts kind\n * of a filter and clicking again does not open anything since the previous text is\n * still there likely filtering all other options out.\"* A combobox whose filter is\n * its own value can only ever re-offer the answer it already has — after picking\n * \"REWE\" the list reopens holding one row, the one you are looking at, and changing\n * your mind means clearing the field by hand first.\n *\n * So the text filters only while it is being TYPED; opening the list, by focus or\n * by clicking the field again, puts the whole pool back. {@link InlineEntityCombobox}\n * below has had this since it was written (see its `typedQuery`) and the app's\n * category-name field grew its own copy to get it — this one, the payee field, was\n * the one left with the bug.\n *\n * Not derived from \"does the text exactly match an option\": a custom value that\n * happens to collide with one would then behave differently from every other, and\n * what is being tracked is what the user just did, which is not a property of the\n * string.\n */\n const [typing, setTyping] = useState(false);\n\n const query = typing ? value.trim() : \"\";\n // Folded as every kit list folds (DUP-5): \"zurich\" finds Zürich, by the locale's case.\n const locale = useKitLocale();\n const matches = useMemo(() => {\n const seen = new Set<string>();\n const uniq = options.filter((o) => o && !seen.has(o) && seen.add(o));\n let ranked: string[];\n const cap = maxSuggestions ?? (isPhone ? 50 : 8);\n if (!query) {\n ranked = uniq.slice(0, cap);\n } else {\n // The whole name, then prefix matches, then substring matches — most relevant on\n // top (`rankMatch`; a stable sort keeps the pool's order within a rank).\n ranked = uniq\n .map((o) => ({ o, rank: rankMatch(o, query, { locale }) }))\n .filter((x) => x.rank >= 0)\n .sort((a, b) => a.rank - b.rank)\n .map((x) => x.o)\n .slice(0, cap);\n }\n if (!groupBy) return ranked;\n // Keep each group contiguous so a heading appears once instead of every time\n // the ranking interleaves two groups — while preserving \"best match first\":\n // a Map keeps insertion order, so groups come out ordered by their best-ranked\n // member and members keep their rank order inside the group.\n const byGroup = new Map<string, string[]>();\n for (const o of ranked) {\n const key = groupBy(o);\n if (!byGroup.has(key)) byGroup.set(key, []);\n byGroup.get(key)!.push(o);\n }\n return [...byGroup.values()].flat();\n }, [options, query, groupBy, maxSuggestions, isPhone, locale]);\n\n /**\n * Which rows OPEN a group, and with what heading — computed once for both lists.\n *\n * The desktop list had this as `matches[i - 1]`, which is free; the sheet had it as\n * `matches[matches.indexOf(o) - 1]`, which is a scan of the list per row and another\n * per heading test. That is quadratic in the sheet's own cap of 50, on the branch\n * that runs on the slowest hardware we ship to — and it answers the wrong question\n * besides: `indexOf` finds the FIRST row with that string, so a duplicated option\n * would have compared against a neighbour it is not next to. (`matches` de-duplicates\n * today; that is a property of the memo above, not of the row being rendered.)\n */\n const groupHeadings = useMemo(() => {\n if (!groupBy) return null;\n const heads = new Map<number, string>();\n let prev: string | undefined;\n matches.forEach((o, i) => {\n const group = groupBy(o);\n if (group != null && group !== prev) heads.set(i, group);\n prev = group;\n });\n return heads;\n }, [matches, groupBy]);\n\n const take = (v: string) => {\n if (locked) return;\n onChange(v);\n setOpen(false);\n setActive(-1);\n // What is in the field is now an ANSWER, not a query — so reopening offers the\n // whole pool again rather than a one-row filter of the value just chosen.\n setTyping(false);\n };\n\n // Offered only when the typed text is genuinely NOT in the pool: an exact\n // (case-insensitive) match is an existing entry the list is already showing, and a\n // second way to pick it would be noise. Compared against `options`, not `matches` —\n // `matches` is capped, so a pool of 300 payees would otherwise offer to \"add\" one\n // that exists but fell off the end of the list.\n // What the field claims the keyboard is on. Only on a pointer device: the phone\n // renders its rows in a {@link PickerSheet} whose own search box owns focus, and a\n // field pointing at an id that is not there is worse than one pointing nowhere.\n const activeId = !isPhone && active >= 0 && active < matches.length ? optionId(active) : undefined;\n useActiveOptionScroll(activeId);\n\n // While locked the field sits in the reason's Tooltip — in a fragment with a hidden\n // copy of the reason, Button's anatomy; `block` so a full-width field stays full width.\n const withLock = (box: ReactNode) =>\n locked ? (\n <LockedReason lock={lock} display={disabledReasonDisplay} className=\"block\">\n {box}\n </LockedReason>\n ) : (\n box\n );\n\n const typed = value.trim();\n const createRow =\n createLabel && typed.length > 0 && !options.some((o) => o.toLowerCase() === typed.toLowerCase())\n ? createLabel(typed)\n : null;\n\n // The list is a layer on the Escape stack while it SHOWS (Wave 2, DUP-1): an Escape\n // over an empty \"open\" list is the dialog's, as it always was. On a phone the sheet\n // is the layer.\n const showing = !isPhone && open && !inert && (matches.length > 0 || Boolean(createRow));\n useDismissLayer(showing, () => {\n setOpen(false);\n setActive(-1);\n });\n\n return (\n // `rest` dresses the outer box — a `data-tour` anchor, a test id. Not the NAME,\n // description, invalid or required state: those belong on the <input> below,\n // which is the combobox a reader meets. Spread FIRST, so the field's ARIA and the\n // handlers carrying live #309 and dev#549 cannot be replaced from outside.\n <div {...rest} ref={wrapperRef} className={cn(\"relative\", className)}>\n {/* A real <label for> since lenkbank's tests met a field `getByLabelText` could\n not find: it used to be a <span>, with the text copied onto `aria-label`. The\n name a reader hears is the same words either way. With a \"?\" it moves into a\n row after the field, below. */}\n {label !== undefined && hintParts.labelHint === undefined && (\n <ComboboxFieldLabel htmlFor={fieldId} className={inert ? \"opacity-50\" : undefined}>\n {label}\n </ComboboxFieldLabel>\n )}\n <EndHintRow {...endHintRowProps(\"hint\" in props, label !== undefined, hintParts.labelHint)}>\n {withLock(\n /* The chevron centers against this inner wrapper, which hugs the input.\n The outer div can be taller than the input (as a grid item it\n stretches to the row height, e.g. next to the editor's category cell\n with its split button), which used to drag a top-1/2 chevron down to\n the input's bottom edge (feedback #248).\n Dimmed as a whole when disabled (or locked), the way EntityCombobox dims its\n trigger. FIELD_BASE's grey alone left a disabled picker looking like a\n filled-in one beside the pickers that do dim (the label is not the input's\n `peer`, so it is dimmed by hand above). */\n <div ref={fieldRef} className={cn(\"relative\", inert && \"cursor-not-allowed opacity-50\")}>\n <input\n ref={ref}\n id={fieldId}\n value={value}\n placeholder={placeholder}\n // The caller's name, over the <label for> above — which is what names the\n // field otherwise (dev#477: a field with neither announces only its text).\n aria-label={ariaLabel}\n role=\"combobox\"\n aria-expanded={open}\n // The list this field is the mouth of. Required by the role, and the half\n // that was missing: the field said it was expanded and never said what it\n // had expanded, so a reader had no way from the box to the options\n // (ESLint's `role-has-required-aria-props`, the audit's §a11y).\n aria-controls={listboxId}\n aria-activedescendant={activeId}\n aria-autocomplete=\"list\"\n aria-invalid={field.isInvalid || undefined}\n aria-describedby={mergeDescribedBy(field.describedBy, locked && lock.reasonId)}\n aria-required={ariaRequired}\n autoComplete=\"off\"\n // Locked: focusable, `readOnly`, `aria-disabled` — see `lockedKeyDown`.\n disabled={locked ? undefined : disabled}\n readOnly={locked || undefined}\n aria-disabled={locked || undefined}\n // 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.\n autoFocus={autoFocus}\n onBlur={onBlur}\n onMouseDown={primaryOnly.onMouseDown}\n onFocus={() => {\n // A back/forward mouse button focuses this field on its way to\n // navigating; it is not a request to open anything (live #309 rework).\n // A locked field takes focus to say why, and opens nothing.\n if (primaryOnly.fromAuxButton() || locked) return;\n setOpen(true);\n setTyping(false);\n // The sheet carries its own input, so the field behind it must not also\n // pull up the keyboard and scroll the page under the dialog.\n if (isPhone) sheetInputRef.current?.focus();\n }}\n // A CLICK as well as focus — the other half of dev#549. Picking a suggestion\n // closes the list without moving focus (the rows suppress `mousedown` on\n // purpose, so the input never blurred), which means clicking the field again\n // fires no `focus` event at all and the list stayed shut. \"Does not open\n // anything\" was literally true.\n onClick={() => {\n if (locked) return;\n setOpen(true);\n setTyping(false);\n }}\n // `inputMode=\"none\"` rather than readOnly: the field must not look\n // uneditable (FIELD_BASE greys a read-only field since dev#468) and must\n // still take focus — it just has no keyboard of its own, the same trick\n // the amount field uses for the numpad.\n inputMode={isPhone ? \"none\" : undefined}\n onChange={(e) => {\n if (locked) return;\n onChange(e.target.value);\n setOpen(true);\n setActive(-1);\n setTyping(true);\n }}\n onKeyDown={(e) => {\n if (locked) return lockedKeyDown(e);\n if (e.key === \"ArrowDown\") {\n e.preventDefault();\n setOpen(true);\n setActive((i) => Math.min(i + 1, matches.length - 1));\n } else if (e.key === \"ArrowUp\") {\n e.preventDefault();\n setActive((i) => Math.max(i - 1, 0));\n } else if (e.key === \"Enter\") {\n // The Enter that confirms an IME composition is the IME's: it picks no\n // row and submits nothing (BUG-9).\n if (isImeEnter(e)) return;\n if (open && active >= 0 && active < matches.length) {\n e.preventDefault();\n take(matches[active]);\n } else {\n // No row highlighted: the typed text is the answer. `preventDefault`\n // only when a caller is taking Enter, so a plain form submit is\n // otherwise left alone.\n if (onSubmit) e.preventDefault();\n setOpen(false);\n onSubmit?.();\n }\n } else if (e.key === \"Escape\") {\n // A list that is SHOWING is the top of the Escape stack, and the key\n // closes it and stops here: a Modal or FullBleedDialog around the field\n // otherwise closed with it, form and all (round 0.34 BUG-1). With no\n // list on screen the key goes on to whoever wants it.\n if (showing && takeEscape(e)) return;\n setOpen(false);\n setActive(-1);\n } else if (e.key === \"Tab\") {\n // Closes, and lets the browser take the Tab: focus is in THIS input,\n // which is staying, so there is nothing to catch — unlike the pickers\n // whose focus sits inside a portalled panel.\n setOpen(false);\n setActive(-1);\n }\n // Home/End are deliberately absent. The APG gives them to the list only\n // where the combobox is not editable; here the text IS the value, a\n // payee name is long enough to want the caret moved to its start, and\n // the desktop list is capped at 8 rows — so jumping it would be worth\n // almost nothing and would cost the one gesture that field is used with.\n }}\n className={cn(\n FIELD_BASE,\n label !== undefined && FIELD_FLOATING_PAD,\n \"pe-9\",\n field.isInvalid && FIELD_INVALID,\n )}\n />\n <ChevronDown\n aria-hidden\n onMouseDown={(e) => {\n // Toggle on the chevron without stealing focus from the input.\n e.preventDefault();\n if (!inert) setOpen((o) => !o);\n }}\n className={cn(\n \"absolute end-2.5 top-1/2 size-4 -translate-y-1/2 text-placeholder\",\n // The box above does the dimming; dimming here too would halve it again.\n !inert && \"cursor-pointer\",\n )}\n />\n </div>,\n )}\n </EndHintRow>\n {/* The label and its \"?\" share the top strip — after the field in the DOM, so the\n \"?\" follows the control in the tab order, as it does on Input and Select. */}\n {label !== undefined && hintParts.labelHint !== undefined && (\n <StaticLabelRow hint={hintParts.labelHint}>\n <ComboboxFieldLabel htmlFor={fieldId} className={cn(LABEL_IN_ROW, inert && \"opacity-50\")}>\n {label}\n </ComboboxFieldLabel>\n </StaticLabelRow>\n )}\n <FieldCaption parts={hintParts} />\n {field.errorEl}\n {isPhone && (\n <PickerSheet\n open={open && !inert}\n onClose={() => setOpen(false)}\n title={sheetTitle ?? label}\n // The sheet's input IS the field: this is a free-text control, so what is\n // typed here is the value (a brand-new payee is just a name nothing\n // matches), and the list below narrows as it changes.\n query={value}\n onQueryChange={(v) => {\n onChange(v);\n setTyping(true);\n }}\n searchPlaceholder={searchPlaceholder ?? placeholder}\n inputRef={sheetInputRef}\n closeLabel={closeLabel}\n >\n <ul id={listboxId} role=\"listbox\">\n {createRow && (\n // `role=\"option\"` rides the BUTTON, not the <li> around it: an option\n // may not contain a separately focusable control, and the button is\n // what a pointer presses. Same shape `CommandPalette` has always had.\n <li role=\"presentation\">\n <button\n type=\"button\"\n role=\"option\"\n aria-selected={false}\n onClick={() => take(typed)}\n className={cn(SHEET_ROW_CLASS, \"font-medium text-brand\")}\n >\n {createRow}\n </button>\n </li>\n )}\n {matches.map((o, i) => {\n const group = groupHeadings?.get(i);\n return (\n <Fragment key={o}>\n {group !== undefined && (\n <li\n role=\"presentation\"\n className=\"px-4 pb-0.5 pt-3 text-caption font-semibold uppercase tracking-wide text-muted\"\n >\n {group}\n </li>\n )}\n <li role=\"presentation\">\n <button\n type=\"button\"\n role=\"option\"\n aria-selected={o === value}\n onClick={() => take(o)}\n className={cn(\n SHEET_ROW_CLASS,\n optionAdornment && \"flex items-center justify-between gap-2\",\n )}\n >\n {optionAdornment ? <span className=\"truncate-until-large\">{o}</span> : o}\n {optionAdornment?.(o)}\n </button>\n </li>\n </Fragment>\n );\n })}\n </ul>\n </PickerSheet>\n )}\n {showing && (\n <SuggestionList id={listboxId} anchorRef={fieldRef} panelRef={panelRef}>\n {createRow && (\n <li role=\"presentation\">\n <button\n type=\"button\"\n role=\"option\"\n aria-selected={false}\n tabIndex={-1}\n onMouseDown={(e) => {\n // mousedown, like the rows below: the input's blur would otherwise\n // close the list before the click landed.\n e.preventDefault();\n take(typed);\n }}\n className={cn(rowClass(false), \"font-medium text-brand\")}\n >\n {createRow}\n </button>\n </li>\n )}\n {matches.map((o, i) => {\n // Heading at each group boundary only — `matches` is group-contiguous, so\n // the boundaries are a property of the list rather than of the branch\n // drawing it. See `groupHeadings`.\n const group = groupHeadings?.get(i);\n return (\n <Fragment key={o}>\n {group !== undefined && (\n <li\n role=\"presentation\"\n className=\"px-3 pb-0.5 pt-2 text-caption font-semibold uppercase tracking-wide text-muted first:pt-1\"\n >\n {group}\n </li>\n )}\n <li role=\"presentation\">\n <button\n type=\"button\"\n id={optionId(i)}\n role=\"option\"\n // CHOSEN, not highlighted. The row the arrows are on is named by\n // the field's `aria-activedescendant`; saying \"selected\" for it\n // would tell a reader that the row they are passing over is\n // already the answer.\n aria-selected={o === value}\n // Focus stays in the field — that is what\n // `aria-activedescendant` is for — so the rows are not tab stops.\n tabIndex={-1}\n onMouseDown={(e) => {\n // mousedown (not click) so the blur from the input firing first\n // doesn't close the list before the selection registers.\n e.preventDefault();\n take(o);\n }}\n onMouseEnter={() => setActive(i)}\n className={cn(\n rowClass(i === active),\n group != null && \"ps-6\",\n // A row with a badge is a flex row so the label truncates and the\n // badge keeps its width; without one it stays the plain block the\n // other lists render, so nothing shifts for callers that pass none.\n // From Large up the label wraps instead (`truncate-until-large`,\n // text size §10.17, round 0.34 T1), as the badge-less row does.\n optionAdornment && \"flex items-center justify-between gap-2\",\n )}\n >\n {optionAdornment ? <span className=\"truncate-until-large\">{o}</span> : o}\n {optionAdornment?.(o)}\n </button>\n </li>\n </Fragment>\n );\n })}\n </SuggestionList>\n )}\n </div>\n );\n}\n"],"mappings":"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","names":[]}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ButtonHTMLAttributes } from "react";
|
|
1
|
+
import type { ButtonHTMLAttributes, Ref } from "react";
|
|
2
2
|
import { type LabelOverride } from "../i18n/kit-labels.js";
|
|
3
3
|
import type { ButtonSize, ButtonTone, ButtonVariant } from "./button-look.js";
|
|
4
4
|
import type { IconButtonSize, IconButtonTone } from "./icon-button.js";
|
|
@@ -49,6 +49,10 @@ export interface CopyButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonEle
|
|
|
49
49
|
/** Called with the real outcome once the copy settles. */
|
|
50
50
|
onCopied?: (ok: boolean) => void;
|
|
51
51
|
labels?: LabelOverride<CopyButtonLabels>;
|
|
52
|
+
/** The `<button>` in either variant — the {@link IconButton}'s or the {@link Button}'s,
|
|
53
|
+
* never the tooltip's wrapper round it (0.35, round 0.35 R4, the follow-up to API-2).
|
|
54
|
+
* It rides the rest of the props on to that kit button, whose own ref it is. */
|
|
55
|
+
ref?: Ref<HTMLButtonElement>;
|
|
52
56
|
}
|
|
53
57
|
/**
|
|
54
58
|
* A button that copies a value and tells the truth about whether it did.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/components/copy-button.tsx"],"sourcesContent":["import type { ButtonHTMLAttributes } from \"react\";\nimport { Check, Copy, X } from \"lucide-react\";\nimport { cn } from \"../lib/cn\";\nimport { useKitLabels, type LabelOverride } from \"../i18n/kit-labels\";\nimport { useAnnounce } from \"../hooks/use-announce\";\nimport { useCopyToClipboard } from \"../hooks/use-copy-to-clipboard\";\nimport type { CopyState } from \"../hooks/use-copy-to-clipboard\";\nimport { Button } from \"./button\";\nimport { IconButton } from \"./icon-button\";\nimport type { ButtonSize, ButtonTone, ButtonVariant } from \"./button-look\";\nimport type { IconButtonSize, IconButtonTone } from \"./icon-button\";\nimport { Tooltip } from \"./tooltip\";\nimport type { TooltipSide } from \"./tooltip\";\nimport { DEFAULT_COPY_BUTTON_LABELS } from \"./copy-button-labels\";\n\n/** The words a copy button says. The visible three are short on purpose (they\n * replace the button's own text in the `label` variant); the two announcements\n * are the sentences a screen reader hears, since the button's focus never moves. */\nexport interface CopyButtonLabels {\n copy: string;\n copied: string;\n failed: string;\n copiedAnnouncement: string;\n failedAnnouncement: string;\n}\n\n// Defined in ./copy-button-labels, which holds no component code (0.34, KS U8).\nexport { DEFAULT_COPY_BUTTON_LABELS };\n\nexport interface CopyButtonProps\n extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, \"children\" | \"onClick\"> {\n /** What to copy. A function is read at click time — for a value that is expensive\n * to build (a CSV of the table) or that changes under the button. */\n text: string | (() => string);\n /** `icon` (default): a square icon button whose tooltip states the result.\n * `label`: a text button whose words change to the result. */\n variant?: \"icon\" | \"label\";\n /** The idle accessible name — and, in the `label` variant, the visible text:\n * \"Copy IBAN\" says WHAT is copied, which a row of three copy icons needs. Default\n * `copyButton.copy`. */\n label?: string;\n /** The button's own look. Default: `ghost` for the icon, `secondary` for the label. */\n buttonVariant?: ButtonVariant;\n /** The icon variant's IconButton size (default `sm`). The `label` variant takes a\n * Button size, `sm` or `md` — passed only when given, so an existing label button\n * keeps its look (keksdose budget-share-card and jobs-panel wanted the compact one). */\n size?: IconButtonSize | ButtonSize;\n /** The icon variant's IconButton tone (e.g. `muted`, so a row of copy icons is no\n * heavier than the row's other actions); in the `label` variant a Button tone,\n * which applies to `link` / `ghost`. The copied / failed colour still wins. */\n tone?: IconButtonTone | ButtonTone;\n /** Keep the click and Enter/Space from reaching a clickable row around the button,\n * as IconButton's own `stopPropagation` does (keksdose feedback-page, users-panel). */\n stopPropagation?: boolean;\n /** Icon variant only: where the result tooltip opens. */\n tooltipSide?: TooltipSide;\n /** Pass through to the tooltip. Left unset, the Tooltip decides on its own — it\n * portals inside a clipping container (a DataTable's `data-clips` scroller). */\n tooltipPortal?: boolean;\n /** ms until the button returns to idle. Default 2000. */\n resetAfter?: number;\n /** Called with the real outcome once the copy settles. */\n onCopied?: (ok: boolean) => void;\n labels?: LabelOverride<CopyButtonLabels>;\n}\n\nconst ICONS: Record<CopyState, typeof Copy> = { idle: Copy, copied: Check, failed: X };\n\n/**\n * A button that copies a value and tells the truth about whether it did.\n *\n * keksdose asked for it after two of its own copy buttons said \"Copied\" on a build\n * where the copy had failed (no Clipboard API over plain http). This one takes its\n * state from {@link useCopyToClipboard}, which reads the result, and has a `failed`\n * state that looks and sounds different from success.\n *\n * The result is spoken through {@link useAnnounce}: activating the button moves no\n * focus, so without a live region a screen-reader user hears nothing at all. A\n * failure goes through an ASSERTIVE region and a success through a polite one — the\n * failure is the news that changes what they do next.\n *\n * The icon variant keeps the SAME accessible name in every state. A button renaming\n * itself under the focus is announced inconsistently across readers; the live region\n * is the channel that is actually heard.\n */\nexport function CopyButton({\n text,\n variant = \"icon\",\n label,\n buttonVariant,\n size,\n tone,\n stopPropagation,\n tooltipSide = \"top\",\n tooltipPortal,\n resetAfter = 2000,\n onCopied,\n labels,\n className,\n disabled,\n ...rest\n}: CopyButtonProps) {\n const words = useKitLabels(\"copyButton\", DEFAULT_COPY_BUTTON_LABELS, labels);\n const { state, copy } = useCopyToClipboard({ resetAfter });\n const polite = useAnnounce();\n const assertive = useAnnounce({ politeness: \"assertive\" });\n\n const idleText = label ?? words.copy;\n const stateText = state === \"copied\" ? words.copied : state === \"failed\" ? words.failed : idleText;\n const Icon = ICONS[state];\n const iconTone =\n state === \"copied\" ? \"text-success\" : state === \"failed\" ? \"text-danger\" : undefined;\n\n const onClick = async () => {\n const ok = await copy(typeof text === \"function\" ? text() : text);\n if (ok) polite.announce(words.copiedAnnouncement);\n else assertive.announce(words.failedAnnouncement);\n onCopied?.(ok);\n };\n\n const regions = (\n <>\n <span {...polite.regionProps} />\n <span {...assertive.regionProps} />\n </>\n );\n\n if (variant === \"label\") {\n return (\n <>\n <Button\n type=\"button\"\n {...rest}\n disabled={disabled}\n variant={buttonVariant ?? \"secondary\"}\n size={size === \"sm\" || size === \"md\" ? size : undefined}\n tone={tone === \"default\" || tone === \"muted\" || tone === \"danger\" ? tone : undefined}\n data-state={state}\n onClick={(e) => {\n if (stopPropagation) e.stopPropagation();\n void onClick();\n }}\n onKeyDown={(e) => {\n if (stopPropagation && (e.key === \"Enter\" || e.key === \" \")) e.stopPropagation();\n rest.onKeyDown?.(e);\n }}\n className={className}\n >\n <Icon aria-hidden className={cn(\"size-4 shrink-0\", iconTone)} />\n {stateText}\n </Button>\n {regions}\n </>\n );\n }\n\n return (\n <>\n <Tooltip label={stateText} side={tooltipSide} portal={tooltipPortal}>\n <IconButton\n type=\"button\"\n {...rest}\n disabled={disabled}\n aria-label={idleText}\n variant={buttonVariant ?? \"ghost\"}\n size={(size ?? \"sm\") as IconButtonSize}\n tone={tone as IconButtonTone | undefined}\n stopPropagation={stopPropagation}\n data-state={state}\n onClick={() => void onClick()}\n className={cn(iconTone, className)}\n >\n <Icon aria-hidden />\n </IconButton>\n </Tooltip>\n {regions}\n </>\n );\n}\n"],"mappings":";
|
|
1
|
+
{"version":3,"sources":["../../src/components/copy-button.tsx"],"sourcesContent":["import type { ButtonHTMLAttributes, Ref } from \"react\";\nimport { Check, Copy, X } from \"lucide-react\";\nimport { cn } from \"../lib/cn\";\nimport { useKitLabels, type LabelOverride } from \"../i18n/kit-labels\";\nimport { useAnnounce } from \"../hooks/use-announce\";\nimport { useCopyToClipboard } from \"../hooks/use-copy-to-clipboard\";\nimport type { CopyState } from \"../hooks/use-copy-to-clipboard\";\nimport { Button } from \"./button\";\nimport { IconButton } from \"./icon-button\";\nimport type { ButtonSize, ButtonTone, ButtonVariant } from \"./button-look\";\nimport type { IconButtonSize, IconButtonTone } from \"./icon-button\";\nimport { Tooltip } from \"./tooltip\";\nimport type { TooltipSide } from \"./tooltip\";\nimport { DEFAULT_COPY_BUTTON_LABELS } from \"./copy-button-labels\";\n\n/** The words a copy button says. The visible three are short on purpose (they\n * replace the button's own text in the `label` variant); the two announcements\n * are the sentences a screen reader hears, since the button's focus never moves. */\nexport interface CopyButtonLabels {\n copy: string;\n copied: string;\n failed: string;\n copiedAnnouncement: string;\n failedAnnouncement: string;\n}\n\n// Defined in ./copy-button-labels, which holds no component code (0.34, KS U8).\nexport { DEFAULT_COPY_BUTTON_LABELS };\n\nexport interface CopyButtonProps\n extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, \"children\" | \"onClick\"> {\n /** What to copy. A function is read at click time — for a value that is expensive\n * to build (a CSV of the table) or that changes under the button. */\n text: string | (() => string);\n /** `icon` (default): a square icon button whose tooltip states the result.\n * `label`: a text button whose words change to the result. */\n variant?: \"icon\" | \"label\";\n /** The idle accessible name — and, in the `label` variant, the visible text:\n * \"Copy IBAN\" says WHAT is copied, which a row of three copy icons needs. Default\n * `copyButton.copy`. */\n label?: string;\n /** The button's own look. Default: `ghost` for the icon, `secondary` for the label. */\n buttonVariant?: ButtonVariant;\n /** The icon variant's IconButton size (default `sm`). The `label` variant takes a\n * Button size, `sm` or `md` — passed only when given, so an existing label button\n * keeps its look (keksdose budget-share-card and jobs-panel wanted the compact one). */\n size?: IconButtonSize | ButtonSize;\n /** The icon variant's IconButton tone (e.g. `muted`, so a row of copy icons is no\n * heavier than the row's other actions); in the `label` variant a Button tone,\n * which applies to `link` / `ghost`. The copied / failed colour still wins. */\n tone?: IconButtonTone | ButtonTone;\n /** Keep the click and Enter/Space from reaching a clickable row around the button,\n * as IconButton's own `stopPropagation` does (keksdose feedback-page, users-panel). */\n stopPropagation?: boolean;\n /** Icon variant only: where the result tooltip opens. */\n tooltipSide?: TooltipSide;\n /** Pass through to the tooltip. Left unset, the Tooltip decides on its own — it\n * portals inside a clipping container (a DataTable's `data-clips` scroller). */\n tooltipPortal?: boolean;\n /** ms until the button returns to idle. Default 2000. */\n resetAfter?: number;\n /** Called with the real outcome once the copy settles. */\n onCopied?: (ok: boolean) => void;\n labels?: LabelOverride<CopyButtonLabels>;\n /** The `<button>` in either variant — the {@link IconButton}'s or the {@link Button}'s,\n * never the tooltip's wrapper round it (0.35, round 0.35 R4, the follow-up to API-2).\n * It rides the rest of the props on to that kit button, whose own ref it is. */\n ref?: Ref<HTMLButtonElement>;\n}\n\nconst ICONS: Record<CopyState, typeof Copy> = { idle: Copy, copied: Check, failed: X };\n\n/**\n * A button that copies a value and tells the truth about whether it did.\n *\n * keksdose asked for it after two of its own copy buttons said \"Copied\" on a build\n * where the copy had failed (no Clipboard API over plain http). This one takes its\n * state from {@link useCopyToClipboard}, which reads the result, and has a `failed`\n * state that looks and sounds different from success.\n *\n * The result is spoken through {@link useAnnounce}: activating the button moves no\n * focus, so without a live region a screen-reader user hears nothing at all. A\n * failure goes through an ASSERTIVE region and a success through a polite one — the\n * failure is the news that changes what they do next.\n *\n * The icon variant keeps the SAME accessible name in every state. A button renaming\n * itself under the focus is announced inconsistently across readers; the live region\n * is the channel that is actually heard.\n */\nexport function CopyButton({\n text,\n variant = \"icon\",\n label,\n buttonVariant,\n size,\n tone,\n stopPropagation,\n tooltipSide = \"top\",\n tooltipPortal,\n resetAfter = 2000,\n onCopied,\n labels,\n className,\n disabled,\n ...rest\n}: CopyButtonProps) {\n const words = useKitLabels(\"copyButton\", DEFAULT_COPY_BUTTON_LABELS, labels);\n const { state, copy } = useCopyToClipboard({ resetAfter });\n const polite = useAnnounce();\n const assertive = useAnnounce({ politeness: \"assertive\" });\n\n const idleText = label ?? words.copy;\n const stateText = state === \"copied\" ? words.copied : state === \"failed\" ? words.failed : idleText;\n const Icon = ICONS[state];\n const iconTone =\n state === \"copied\" ? \"text-success\" : state === \"failed\" ? \"text-danger\" : undefined;\n\n const onClick = async () => {\n const ok = await copy(typeof text === \"function\" ? text() : text);\n if (ok) polite.announce(words.copiedAnnouncement);\n else assertive.announce(words.failedAnnouncement);\n onCopied?.(ok);\n };\n\n const regions = (\n <>\n <span {...polite.regionProps} />\n <span {...assertive.regionProps} />\n </>\n );\n\n if (variant === \"label\") {\n return (\n <>\n <Button\n type=\"button\"\n {...rest}\n disabled={disabled}\n variant={buttonVariant ?? \"secondary\"}\n size={size === \"sm\" || size === \"md\" ? size : undefined}\n tone={tone === \"default\" || tone === \"muted\" || tone === \"danger\" ? tone : undefined}\n data-state={state}\n onClick={(e) => {\n if (stopPropagation) e.stopPropagation();\n void onClick();\n }}\n onKeyDown={(e) => {\n if (stopPropagation && (e.key === \"Enter\" || e.key === \" \")) e.stopPropagation();\n rest.onKeyDown?.(e);\n }}\n className={className}\n >\n <Icon aria-hidden className={cn(\"size-4 shrink-0\", iconTone)} />\n {stateText}\n </Button>\n {regions}\n </>\n );\n }\n\n return (\n <>\n <Tooltip label={stateText} side={tooltipSide} portal={tooltipPortal}>\n <IconButton\n type=\"button\"\n {...rest}\n disabled={disabled}\n aria-label={idleText}\n variant={buttonVariant ?? \"ghost\"}\n size={(size ?? \"sm\") as IconButtonSize}\n tone={tone as IconButtonTone | undefined}\n stopPropagation={stopPropagation}\n data-state={state}\n onClick={() => void onClick()}\n className={cn(iconTone, className)}\n >\n <Icon aria-hidden />\n </IconButton>\n </Tooltip>\n {regions}\n </>\n );\n}\n"],"mappings":";AA6HI,mBACE,KADF;AA5HJ,SAAS,OAAO,MAAM,SAAS;AAC/B,SAAS,UAAU;AACnB,SAAS,oBAAwC;AACjD,SAAS,mBAAmB;AAC5B,SAAS,0BAA0B;AAEnC,SAAS,cAAc;AACvB,SAAS,kBAAkB;AAG3B,SAAS,eAAe;AAExB,SAAS,kCAAkC;AAyD3C,MAAM,QAAwC,EAAE,MAAM,MAAM,QAAQ,OAAO,QAAQ,EAAE;AAmB9E,SAAS,WAAW;AAAA,EACzB;AAAA,EACA,UAAU;AAAA,EACV;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,cAAc;AAAA,EACd;AAAA,EACA,aAAa;AAAA,EACb;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,GAAG;AACL,GAAoB;AAClB,QAAM,QAAQ,aAAa,cAAc,4BAA4B,MAAM;AAC3E,QAAM,EAAE,OAAO,KAAK,IAAI,mBAAmB,EAAE,WAAW,CAAC;AACzD,QAAM,SAAS,YAAY;AAC3B,QAAM,YAAY,YAAY,EAAE,YAAY,YAAY,CAAC;AAEzD,QAAM,WAAW,SAAS,MAAM;AAChC,QAAM,YAAY,UAAU,WAAW,MAAM,SAAS,UAAU,WAAW,MAAM,SAAS;AAC1F,QAAM,OAAO,MAAM,KAAK;AACxB,QAAM,WACJ,UAAU,WAAW,iBAAiB,UAAU,WAAW,gBAAgB;AAE7E,QAAM,UAAU,YAAY;AAC1B,UAAM,KAAK,MAAM,KAAK,OAAO,SAAS,aAAa,KAAK,IAAI,IAAI;AAChE,QAAI,GAAI,QAAO,SAAS,MAAM,kBAAkB;AAAA,QAC3C,WAAU,SAAS,MAAM,kBAAkB;AAChD,eAAW,EAAE;AAAA,EACf;AAEA,QAAM,UACJ,iCACE;AAAA,wBAAC,UAAM,GAAG,OAAO,aAAa;AAAA,IAC9B,oBAAC,UAAM,GAAG,UAAU,aAAa;AAAA,KACnC;AAGF,MAAI,YAAY,SAAS;AACvB,WACE,iCACE;AAAA;AAAA,QAAC;AAAA;AAAA,UACC,MAAK;AAAA,UACJ,GAAG;AAAA,UACJ;AAAA,UACA,SAAS,iBAAiB;AAAA,UAC1B,MAAM,SAAS,QAAQ,SAAS,OAAO,OAAO;AAAA,UAC9C,MAAM,SAAS,aAAa,SAAS,WAAW,SAAS,WAAW,OAAO;AAAA,UAC3E,cAAY;AAAA,UACZ,SAAS,CAAC,MAAM;AACd,gBAAI,gBAAiB,GAAE,gBAAgB;AACvC,iBAAK,QAAQ;AAAA,UACf;AAAA,UACA,WAAW,CAAC,MAAM;AAChB,gBAAI,oBAAoB,EAAE,QAAQ,WAAW,EAAE,QAAQ,KAAM,GAAE,gBAAgB;AAC/E,iBAAK,YAAY,CAAC;AAAA,UACpB;AAAA,UACA;AAAA,UAEA;AAAA,gCAAC,QAAK,eAAW,MAAC,WAAW,GAAG,mBAAmB,QAAQ,GAAG;AAAA,YAC7D;AAAA;AAAA;AAAA,MACH;AAAA,MACC;AAAA,OACH;AAAA,EAEJ;AAEA,SACE,iCACE;AAAA,wBAAC,WAAQ,OAAO,WAAW,MAAM,aAAa,QAAQ,eACpD;AAAA,MAAC;AAAA;AAAA,QACC,MAAK;AAAA,QACJ,GAAG;AAAA,QACJ;AAAA,QACA,cAAY;AAAA,QACZ,SAAS,iBAAiB;AAAA,QAC1B,MAAO,QAAQ;AAAA,QACf;AAAA,QACA;AAAA,QACA,cAAY;AAAA,QACZ,SAAS,MAAM,KAAK,QAAQ;AAAA,QAC5B,WAAW,GAAG,UAAU,SAAS;AAAA,QAEjC,8BAAC,QAAK,eAAW,MAAC;AAAA;AAAA,IACpB,GACF;AAAA,IACC;AAAA,KACH;AAEJ;","names":[]}
|
|
@@ -1,13 +1,8 @@
|
|
|
1
1
|
import type { ComponentPropsWithRef, ReactNode } from "react";
|
|
2
2
|
import { type ComboboxCore } from "./combobox-core.js";
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
symbol: string;
|
|
7
|
-
/** ISO 3166-1 alpha-2 country code (lowercase) used by flag-icons. */
|
|
8
|
-
country: string;
|
|
9
|
-
}
|
|
10
|
-
export declare const CURRENCIES: CurrencyOption[];
|
|
3
|
+
import { CURRENCIES, type CurrencyOption } from "../currencies.js";
|
|
4
|
+
export { CURRENCIES };
|
|
5
|
+
export type { CurrencyOption };
|
|
11
6
|
export interface CurrencyFlagProps extends ComponentPropsWithRef<"span"> {
|
|
12
7
|
/** ISO 3166-1 alpha-2, lowercase — the class flag-icons keys its sprite by. */
|
|
13
8
|
country: string;
|
|
@@ -4,35 +4,7 @@ import { useMemo } from "react";
|
|
|
4
4
|
import { cn } from "../lib/cn.js";
|
|
5
5
|
import { foldForSearch } from "../lib/text-match.js";
|
|
6
6
|
import { ComboboxPanel } from "./combobox-core.js";
|
|
7
|
-
|
|
8
|
-
{ code: "EUR", name: "Euro", symbol: "\u20AC", country: "eu" },
|
|
9
|
-
{ code: "USD", name: "US Dollar", symbol: "$", country: "us" },
|
|
10
|
-
{ code: "GBP", name: "British Pound", symbol: "\xA3", country: "gb" },
|
|
11
|
-
{ code: "CHF", name: "Swiss Franc", symbol: "CHF", country: "ch" },
|
|
12
|
-
{ code: "JPY", name: "Japanese Yen", symbol: "\xA5", country: "jp" },
|
|
13
|
-
{ code: "CAD", name: "Canadian Dollar", symbol: "CA$", country: "ca" },
|
|
14
|
-
{ code: "AUD", name: "Australian Dollar", symbol: "A$", country: "au" },
|
|
15
|
-
{ code: "NZD", name: "New Zealand Dollar", symbol: "NZ$", country: "nz" },
|
|
16
|
-
{ code: "SEK", name: "Swedish Krona", symbol: "kr", country: "se" },
|
|
17
|
-
{ code: "NOK", name: "Norwegian Krone", symbol: "kr", country: "no" },
|
|
18
|
-
{ code: "DKK", name: "Danish Krone", symbol: "kr", country: "dk" },
|
|
19
|
-
{ code: "PLN", name: "Polish Zloty", symbol: "z\u0142", country: "pl" },
|
|
20
|
-
{ code: "CZK", name: "Czech Koruna", symbol: "K\u010D", country: "cz" },
|
|
21
|
-
{ code: "HUF", name: "Hungarian Forint", symbol: "Ft", country: "hu" },
|
|
22
|
-
{ code: "RON", name: "Romanian Leu", symbol: "lei", country: "ro" },
|
|
23
|
-
{ code: "BGN", name: "Bulgarian Lev", symbol: "\u043B\u0432", country: "bg" },
|
|
24
|
-
{ code: "TRY", name: "Turkish Lira", symbol: "\u20BA", country: "tr" },
|
|
25
|
-
{ code: "RUB", name: "Russian Ruble", symbol: "\u20BD", country: "ru" },
|
|
26
|
-
{ code: "CNY", name: "Chinese Yuan", symbol: "\xA5", country: "cn" },
|
|
27
|
-
{ code: "HKD", name: "Hong Kong Dollar", symbol: "HK$", country: "hk" },
|
|
28
|
-
{ code: "SGD", name: "Singapore Dollar", symbol: "S$", country: "sg" },
|
|
29
|
-
{ code: "INR", name: "Indian Rupee", symbol: "\u20B9", country: "in" },
|
|
30
|
-
{ code: "BRL", name: "Brazilian Real", symbol: "R$", country: "br" },
|
|
31
|
-
{ code: "MXN", name: "Mexican Peso", symbol: "MX$", country: "mx" },
|
|
32
|
-
{ code: "ZAR", name: "South African Rand", symbol: "R", country: "za" },
|
|
33
|
-
{ code: "ILS", name: "Israeli Shekel", symbol: "\u20AA", country: "il" },
|
|
34
|
-
{ code: "AED", name: "UAE Dirham", symbol: "\u062F.\u0625", country: "ae" }
|
|
35
|
-
];
|
|
7
|
+
import { CURRENCIES } from "../currencies.js";
|
|
36
8
|
function CurrencyFlag({ country, className, ...rest }) {
|
|
37
9
|
return /* @__PURE__ */ jsx(
|
|
38
10
|
"span",
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/components/currency-option-list.tsx"],"sourcesContent":["import { useMemo } from \"react\";\nimport type { ComponentPropsWithRef, ReactNode } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { foldForSearch } from \"../lib/text-match\";\nimport { ComboboxPanel, type ComboboxCore, type ComboOption } from \"./combobox-core\";\n\n// The currency data and the one list both currency pickers render (round 0.34 Wave 2,\n// DUP-4). CurrencySelect re-exports the data under its own name, as it always has; the\n// list is package-private. Its own module, so AmountInput can render the list without a\n// cycle through currency-select.\n\nexport interface CurrencyOption {\n code: string;\n name: string;\n symbol: string;\n /** ISO 3166-1 alpha-2 country code (lowercase) used by flag-icons. */\n country: string;\n}\n\nexport const CURRENCIES: CurrencyOption[] = [\n { code: \"EUR\", name: \"Euro\", symbol: \"€\", country: \"eu\" },\n { code: \"USD\", name: \"US Dollar\", symbol: \"$\", country: \"us\" },\n { code: \"GBP\", name: \"British Pound\", symbol: \"£\", country: \"gb\" },\n { code: \"CHF\", name: \"Swiss Franc\", symbol: \"CHF\", country: \"ch\" },\n { code: \"JPY\", name: \"Japanese Yen\", symbol: \"¥\", country: \"jp\" },\n { code: \"CAD\", name: \"Canadian Dollar\", symbol: \"CA$\", country: \"ca\" },\n { code: \"AUD\", name: \"Australian Dollar\", symbol: \"A$\", country: \"au\" },\n { code: \"NZD\", name: \"New Zealand Dollar\", symbol: \"NZ$\", country: \"nz\" },\n { code: \"SEK\", name: \"Swedish Krona\", symbol: \"kr\", country: \"se\" },\n { code: \"NOK\", name: \"Norwegian Krone\", symbol: \"kr\", country: \"no\" },\n { code: \"DKK\", name: \"Danish Krone\", symbol: \"kr\", country: \"dk\" },\n { code: \"PLN\", name: \"Polish Zloty\", symbol: \"zł\", country: \"pl\" },\n { code: \"CZK\", name: \"Czech Koruna\", symbol: \"Kč\", country: \"cz\" },\n { code: \"HUF\", name: \"Hungarian Forint\", symbol: \"Ft\", country: \"hu\" },\n { code: \"RON\", name: \"Romanian Leu\", symbol: \"lei\", country: \"ro\" },\n { code: \"BGN\", name: \"Bulgarian Lev\", symbol: \"лв\", country: \"bg\" },\n { code: \"TRY\", name: \"Turkish Lira\", symbol: \"₺\", country: \"tr\" },\n { code: \"RUB\", name: \"Russian Ruble\", symbol: \"₽\", country: \"ru\" },\n { code: \"CNY\", name: \"Chinese Yuan\", symbol: \"¥\", country: \"cn\" },\n { code: \"HKD\", name: \"Hong Kong Dollar\", symbol: \"HK$\", country: \"hk\" },\n { code: \"SGD\", name: \"Singapore Dollar\", symbol: \"S$\", country: \"sg\" },\n { code: \"INR\", name: \"Indian Rupee\", symbol: \"₹\", country: \"in\" },\n { code: \"BRL\", name: \"Brazilian Real\", symbol: \"R$\", country: \"br\" },\n { code: \"MXN\", name: \"Mexican Peso\", symbol: \"MX$\", country: \"mx\" },\n { code: \"ZAR\", name: \"South African Rand\", symbol: \"R\", country: \"za\" },\n { code: \"ILS\", name: \"Israeli Shekel\", symbol: \"₪\", country: \"il\" },\n { code: \"AED\", name: \"UAE Dirham\", symbol: \"د.إ\", country: \"ae\" },\n];\n\nexport interface CurrencyFlagProps extends ComponentPropsWithRef<\"span\"> {\n /** ISO 3166-1 alpha-2, lowercase — the class flag-icons keys its sprite by. */\n country: string;\n}\n\nexport function CurrencyFlag({ country, className, ...rest }: CurrencyFlagProps) {\n return (\n <span\n {...rest}\n // Stays `aria-hidden` whatever a caller passes: the flag is a picture of the\n // code printed next to it, and a reader that met both would hear the currency\n // twice.\n aria-hidden\n className={cn(\"fi inline-block w-5 h-[0.9375rem] rounded-sm shadow-[0_0_0_1px_rgba(0,0,0,0.08)] shrink-0\", `fi-${country}`, className)}\n />\n );\n}\n\nconst CURRENCY_BY_CODE = new Map(CURRENCIES.map((c) => [c.code, c]));\n\nexport function getCurrency(code: string | null | undefined): CurrencyOption | undefined {\n return code ? CURRENCY_BY_CODE.get(code.toUpperCase()) : undefined;\n}\n\n/** The name to SHOW for a currency: the caller's translation if they have one for\n * that code; else, when a `locale` is known, the name `Intl.DisplayNames` has for\n * it in that locale; else the English name {@link CURRENCIES} carries. Shared with\n * AmountInput, which renders the same list from the same data.\n *\n * `Intl` only when a locale was actually GIVEN (by prop or `<UiKitProvider>`): an\n * app that never set one keeps the shipped English names it has always shown,\n * rather than whatever language the test runner or the browser happens to be in. */\nexport function currencyName(\n c: CurrencyOption,\n names?: Record<string, string>,\n locale?: string,\n): string {\n const own = names?.[c.code];\n if (own !== undefined) return own;\n if (locale) {\n try {\n const shown = new Intl.DisplayNames([locale], { type: \"currency\" }).of(c.code);\n if (shown && shown !== c.code) return shown;\n } catch {\n // A malformed tag, or a runtime without `DisplayNames`: the shipped name.\n }\n }\n return c.name;\n}\n\nexport interface CurrencyOptionListProps {\n /** The host's `useComboboxCore`: its trigger, its open state, its query. */\n core: ComboboxCore<string>;\n /** What the host's trigger names in `aria-controls`. */\n listboxId: string;\n /** The chosen code, if any — selected and checked in the list. */\n value: string | null | undefined;\n /** A currency was picked. The host closes the list. */\n onChoose: (code: string) => void;\n /** Only these ISO codes, in this order; codes the kit has no definition for are\n * dropped. Default every one of {@link CURRENCIES}. */\n codes?: readonly string[];\n /** Translated names by code — see {@link currencyName}. */\n currencyNames?: Record<string, string>;\n locale?: string;\n searchPlaceholder: string;\n /** The phone sheet's title — the field's name. */\n sheetTitle: ReactNode;\n}\n\n/**\n * The currency list — its filter and its rows — that CurrencySelect and AmountInput's\n * currency chip both render, on the picker family's `ComboboxPanel` (round 0.34 Wave 2,\n * DUP-4 + BUG-12).\n *\n * The chip's list was a hand copy of CurrencySelect's, and it had drifted: the name kept a\n * plain `truncate` where T1 made CurrencySelect's wrap from Large up, it took no `options`\n * subset, it had no check mark and no listbox id, and it was a fixed 16rem where #308 had\n * given CurrencySelect a panel that grows to the full names. And both were lists of plain\n * buttons under a trigger that promised a listbox. On the shared panel they are a listbox\n * of options, the chosen one selected, with the arrow keys, the live result count, the\n * phone sheet (live #200) and portal anchoring (dev#548: an `overflow` ancestor clipped\n * the absolute panel's first third away).\n *\n * The panel hangs from the trigger's END edge and grows toward the start, as wide as the\n * names need up to 20rem and the glass (feedback #308: a field with content after it is\n * not pushed past the page).\n */\nexport function CurrencyOptionList({\n core,\n listboxId,\n value,\n onChoose,\n codes,\n currencyNames,\n locale,\n searchPlaceholder,\n sheetTitle,\n}: CurrencyOptionListProps) {\n // Keyed on the codes' CONTENTS: an inline `options={[\"EUR\", \"CHF\"]}` is a new array\n // every render.\n const codesKey = codes?.join(\",\");\n const pool = useMemo(\n () =>\n codesKey === undefined\n ? CURRENCIES\n : // The caller's order; codes the kit has no definition for are dropped.\n codesKey\n .split(\",\")\n .map((code) => getCurrency(code))\n .filter((c): c is CurrencyOption => c !== undefined),\n [codesKey],\n );\n const named = useMemo(\n () => pool.map((c) => ({ c, name: currencyName(c, currencyNames, locale) })),\n [pool, currencyNames, locale],\n );\n const byCode = useMemo(() => new Map(named.map((n) => [n.c.code, n])), [named]);\n\n // The code, the name the row SHOWS (so a translated list is searchable in the language\n // it is written in) and the symbol — folded as every kit list folds (DUP-5).\n const query = core.query;\n const results = useMemo(() => {\n const q = foldForSearch(query.trim(), locale);\n const hits = q\n ? named.filter(({ c, name }) => [c.code, name, c.symbol].some((s) => foldForSearch(s, locale).includes(q)))\n : named;\n return hits.map(\n ({ c, name }): ComboOption<string> => ({\n value: c.code,\n label: name,\n icon: <CurrencyFlag country={c.country} />,\n }),\n );\n }, [named, query, locale]);\n\n const chosen = getCurrency(value)?.code;\n return (\n <ComboboxPanel\n // The core with the currency filter's rows in place of its own.\n core={{ ...core, results }}\n listboxId={listboxId}\n searchPlaceholder={searchPlaceholder}\n sheetTitle={sheetTitle}\n isSelected={(code) => code === chosen}\n onChoose={(o) => onChoose(o.value)}\n showCreate={false}\n onCreate={() => {}}\n createContent={null}\n width={{ fit: true, maxRem: 20 }}\n align=\"end\"\n renderOption={(o) => {\n const row = byCode.get(o.value);\n if (!row) return o.label;\n return (\n <>\n <span className=\"w-8 shrink-0 font-mono text-muted\">{row.c.symbol}</span>{\" \"}\n <span className=\"shrink-0 font-medium text-primary\">{row.c.code}</span>{\" \"}\n {/* The name wraps from Large up (round 0.34 T1); the code beside it is what a\n reader scans for, and it never shrinks. */}\n <span className=\"min-w-0 truncate-until-large text-muted\">{row.name}</span>\n </>\n );\n }}\n />\n );\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/components/currency-option-list.tsx"],"sourcesContent":["import { useMemo } from \"react\";\nimport type { ComponentPropsWithRef, ReactNode } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { foldForSearch } from \"../lib/text-match\";\nimport { ComboboxPanel, type ComboboxCore, type ComboOption } from \"./combobox-core\";\nimport { CURRENCIES, type CurrencyOption } from \"../currencies\";\n\n// The one list both currency pickers render (round 0.34 Wave 2, DUP-4). CurrencySelect\n// re-exports the data under its own name, as it always has; the list is package-private.\n// Its own module, so AmountInput can render the list without a cycle through\n// currency-select. The data itself is ../currencies since 0.35: an entry of its own that\n// imports nothing, which an app's build can read in Node (keksdose's flag subset).\nexport { CURRENCIES };\nexport type { CurrencyOption };\n\nexport interface CurrencyFlagProps extends ComponentPropsWithRef<\"span\"> {\n /** ISO 3166-1 alpha-2, lowercase — the class flag-icons keys its sprite by. */\n country: string;\n}\n\nexport function CurrencyFlag({ country, className, ...rest }: CurrencyFlagProps) {\n return (\n <span\n {...rest}\n // Stays `aria-hidden` whatever a caller passes: the flag is a picture of the\n // code printed next to it, and a reader that met both would hear the currency\n // twice.\n aria-hidden\n className={cn(\"fi inline-block w-5 h-[0.9375rem] rounded-sm shadow-[0_0_0_1px_rgba(0,0,0,0.08)] shrink-0\", `fi-${country}`, className)}\n />\n );\n}\n\nconst CURRENCY_BY_CODE = new Map(CURRENCIES.map((c) => [c.code, c]));\n\nexport function getCurrency(code: string | null | undefined): CurrencyOption | undefined {\n return code ? CURRENCY_BY_CODE.get(code.toUpperCase()) : undefined;\n}\n\n/** The name to SHOW for a currency: the caller's translation if they have one for\n * that code; else, when a `locale` is known, the name `Intl.DisplayNames` has for\n * it in that locale; else the English name {@link CURRENCIES} carries. Shared with\n * AmountInput, which renders the same list from the same data.\n *\n * `Intl` only when a locale was actually GIVEN (by prop or `<UiKitProvider>`): an\n * app that never set one keeps the shipped English names it has always shown,\n * rather than whatever language the test runner or the browser happens to be in. */\nexport function currencyName(\n c: CurrencyOption,\n names?: Record<string, string>,\n locale?: string,\n): string {\n const own = names?.[c.code];\n if (own !== undefined) return own;\n if (locale) {\n try {\n const shown = new Intl.DisplayNames([locale], { type: \"currency\" }).of(c.code);\n if (shown && shown !== c.code) return shown;\n } catch {\n // A malformed tag, or a runtime without `DisplayNames`: the shipped name.\n }\n }\n return c.name;\n}\n\nexport interface CurrencyOptionListProps {\n /** The host's `useComboboxCore`: its trigger, its open state, its query. */\n core: ComboboxCore<string>;\n /** What the host's trigger names in `aria-controls`. */\n listboxId: string;\n /** The chosen code, if any — selected and checked in the list. */\n value: string | null | undefined;\n /** A currency was picked. The host closes the list. */\n onChoose: (code: string) => void;\n /** Only these ISO codes, in this order; codes the kit has no definition for are\n * dropped. Default every one of {@link CURRENCIES}. */\n codes?: readonly string[];\n /** Translated names by code — see {@link currencyName}. */\n currencyNames?: Record<string, string>;\n locale?: string;\n searchPlaceholder: string;\n /** The phone sheet's title — the field's name. */\n sheetTitle: ReactNode;\n}\n\n/**\n * The currency list — its filter and its rows — that CurrencySelect and AmountInput's\n * currency chip both render, on the picker family's `ComboboxPanel` (round 0.34 Wave 2,\n * DUP-4 + BUG-12).\n *\n * The chip's list was a hand copy of CurrencySelect's, and it had drifted: the name kept a\n * plain `truncate` where T1 made CurrencySelect's wrap from Large up, it took no `options`\n * subset, it had no check mark and no listbox id, and it was a fixed 16rem where #308 had\n * given CurrencySelect a panel that grows to the full names. And both were lists of plain\n * buttons under a trigger that promised a listbox. On the shared panel they are a listbox\n * of options, the chosen one selected, with the arrow keys, the live result count, the\n * phone sheet (live #200) and portal anchoring (dev#548: an `overflow` ancestor clipped\n * the absolute panel's first third away).\n *\n * The panel hangs from the trigger's END edge and grows toward the start, as wide as the\n * names need up to 20rem and the glass (feedback #308: a field with content after it is\n * not pushed past the page).\n */\nexport function CurrencyOptionList({\n core,\n listboxId,\n value,\n onChoose,\n codes,\n currencyNames,\n locale,\n searchPlaceholder,\n sheetTitle,\n}: CurrencyOptionListProps) {\n // Keyed on the codes' CONTENTS: an inline `options={[\"EUR\", \"CHF\"]}` is a new array\n // every render.\n const codesKey = codes?.join(\",\");\n const pool = useMemo(\n () =>\n codesKey === undefined\n ? CURRENCIES\n : // The caller's order; codes the kit has no definition for are dropped.\n codesKey\n .split(\",\")\n .map((code) => getCurrency(code))\n .filter((c): c is CurrencyOption => c !== undefined),\n [codesKey],\n );\n const named = useMemo(\n () => pool.map((c) => ({ c, name: currencyName(c, currencyNames, locale) })),\n [pool, currencyNames, locale],\n );\n const byCode = useMemo(() => new Map(named.map((n) => [n.c.code, n])), [named]);\n\n // The code, the name the row SHOWS (so a translated list is searchable in the language\n // it is written in) and the symbol — folded as every kit list folds (DUP-5).\n const query = core.query;\n const results = useMemo(() => {\n const q = foldForSearch(query.trim(), locale);\n const hits = q\n ? named.filter(({ c, name }) => [c.code, name, c.symbol].some((s) => foldForSearch(s, locale).includes(q)))\n : named;\n return hits.map(\n ({ c, name }): ComboOption<string> => ({\n value: c.code,\n label: name,\n icon: <CurrencyFlag country={c.country} />,\n }),\n );\n }, [named, query, locale]);\n\n const chosen = getCurrency(value)?.code;\n return (\n <ComboboxPanel\n // The core with the currency filter's rows in place of its own.\n core={{ ...core, results }}\n listboxId={listboxId}\n searchPlaceholder={searchPlaceholder}\n sheetTitle={sheetTitle}\n isSelected={(code) => code === chosen}\n onChoose={(o) => onChoose(o.value)}\n showCreate={false}\n onCreate={() => {}}\n createContent={null}\n width={{ fit: true, maxRem: 20 }}\n align=\"end\"\n renderOption={(o) => {\n const row = byCode.get(o.value);\n if (!row) return o.label;\n return (\n <>\n <span className=\"w-8 shrink-0 font-mono text-muted\">{row.c.symbol}</span>{\" \"}\n <span className=\"shrink-0 font-medium text-primary\">{row.c.code}</span>{\" \"}\n {/* The name wraps from Large up (round 0.34 T1); the code beside it is what a\n reader scans for, and it never shrinks. */}\n <span className=\"min-w-0 truncate-until-large text-muted\">{row.name}</span>\n </>\n );\n }}\n />\n );\n}\n"],"mappings":"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","names":[]}
|
|
@@ -49,7 +49,7 @@ export interface CurrentPasswordInputProps extends Omit<InputProps, "type" | "au
|
|
|
49
49
|
* is named another way (`aria-label` / `aria-labelledby`), which then stays the only
|
|
50
50
|
* name. Pass the app's own word on a sign-in form ("Password", "Your password"), or
|
|
51
51
|
* `null` for a field whose `<label htmlFor>` is drawn elsewhere (a form library's
|
|
52
|
-
*
|
|
52
|
+
* label), which would otherwise be labelled twice.
|
|
53
53
|
*/
|
|
54
54
|
label?: ReactNode;
|
|
55
55
|
/** The value as a string — the `(next) => …` shape, so a caller holding it in
|
|
@@ -81,8 +81,8 @@ export interface CurrentPasswordInputProps extends Omit<InputProps, "type" | "au
|
|
|
81
81
|
*
|
|
82
82
|
* Everything else is {@link Input}: the reveal toggle, `error` (under the field,
|
|
83
83
|
* `aria-describedby`, `aria-invalid`), `invalid`, `name`, `id`, `autoFocus`, `required`.
|
|
84
|
-
* The ref is the `<input>`, so it works with `@eifi1/ui-kit/rhf`'s `
|
|
85
|
-
*
|
|
84
|
+
* The ref is the `<input>`, so it works with `@eifi1/ui-kit/rhf`'s `RhfField` / a
|
|
85
|
+
* `register()` spread, and controlled or uncontrolled.
|
|
86
86
|
*
|
|
87
87
|
* ```tsx
|
|
88
88
|
* <CurrentPasswordInput name="password" label={t("password")} error={errors.password?.message}
|