@eifi1/ui-kit 0.21.0 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +42 -25
- package/dist/chart.d.ts +19 -14
- package/dist/components/account-chips.d.ts +10 -3
- package/dist/components/account-chips.js +5 -2
- package/dist/components/account-chips.js.map +1 -1
- package/dist/components/account-settings.d.ts +33 -2
- package/dist/components/account-settings.js +43 -21
- package/dist/components/account-settings.js.map +1 -1
- package/dist/components/amount-input.d.ts +53 -14
- package/dist/components/amount-input.js +17 -28
- package/dist/components/amount-input.js.map +1 -1
- package/dist/components/autocomplete.d.ts +29 -0
- package/dist/components/autocomplete.js +134 -104
- package/dist/components/autocomplete.js.map +1 -1
- package/dist/components/button-group.d.ts +19 -14
- package/dist/components/calculator.d.ts +19 -14
- package/dist/components/checkbox-group.d.ts +104 -0
- package/dist/components/checkbox-group.js +111 -0
- package/dist/components/checkbox-group.js.map +1 -0
- package/dist/components/checkbox.d.ts +19 -0
- package/dist/components/checkbox.js +30 -7
- package/dist/components/checkbox.js.map +1 -1
- package/dist/components/chip.d.ts +18 -0
- package/dist/components/chip.js +47 -10
- package/dist/components/chip.js.map +1 -1
- package/dist/components/column-mapper.d.ts +182 -0
- package/dist/components/column-mapper.js +375 -0
- package/dist/components/column-mapper.js.map +1 -0
- package/dist/components/combobox-core.d.ts +39 -2
- package/dist/components/combobox-core.js +29 -6
- package/dist/components/combobox-core.js.map +1 -1
- package/dist/components/combobox.d.ts +91 -2
- package/dist/components/combobox.js +454 -308
- package/dist/components/combobox.js.map +1 -1
- package/dist/components/confirm-dialog.d.ts +69 -161
- package/dist/components/confirm-dialog.js +7 -8
- package/dist/components/confirm-dialog.js.map +1 -1
- package/dist/components/copy-button.d.ts +16 -11
- package/dist/components/country-select.d.ts +167 -0
- package/dist/components/country-select.js +293 -0
- package/dist/components/country-select.js.map +1 -0
- package/dist/components/currency-select.d.ts +17 -1
- package/dist/components/currency-select.js +56 -23
- package/dist/components/currency-select.js.map +1 -1
- package/dist/components/danger-confirm.d.ts +69 -166
- package/dist/components/danger-confirm.js +144 -50
- package/dist/components/danger-confirm.js.map +1 -1
- package/dist/components/data-table-cells.d.ts +1 -2
- package/dist/components/data-table-filter-popover.d.ts +1 -2
- package/dist/components/data-table-filters.d.ts +1 -2
- package/dist/components/data-table-labels.d.ts +4 -97
- package/dist/components/data-table-labels.js +9 -1
- package/dist/components/data-table-labels.js.map +1 -1
- package/dist/components/data-table-pagination.d.ts +3 -1
- package/dist/components/data-table.d.ts +1 -2
- package/dist/components/data-table.js +98 -3
- package/dist/components/data-table.js.map +1 -1
- package/dist/components/date-picker.d.ts +66 -5
- package/dist/components/date-picker.js +378 -66
- package/dist/components/date-picker.js.map +1 -1
- package/dist/components/entity-combobox.d.ts +42 -1
- package/dist/components/entity-combobox.js +154 -113
- package/dist/components/entity-combobox.js.map +1 -1
- package/dist/components/facing-pair.d.ts +19 -14
- package/dist/components/field-parts.d.ts +224 -0
- package/dist/components/field-parts.js +156 -0
- package/dist/components/field-parts.js.map +1 -0
- package/dist/components/field-strip.d.ts +105 -0
- package/dist/components/field-strip.js +46 -0
- package/dist/components/field-strip.js.map +1 -0
- package/dist/components/file-button.d.ts +19 -14
- package/dist/components/file-button.js +13 -3
- package/dist/components/file-button.js.map +1 -1
- package/dist/components/file-dropzone.d.ts +41 -15
- package/dist/components/file-dropzone.js +57 -34
- package/dist/components/file-dropzone.js.map +1 -1
- package/dist/components/form-actions.d.ts +18 -13
- package/dist/components/form-actions.js +69 -5
- package/dist/components/form-actions.js.map +1 -1
- package/dist/components/iban-input.d.ts +69 -0
- package/dist/components/iban-input.js +125 -0
- package/dist/components/iban-input.js.map +1 -0
- package/dist/components/icon-picker.d.ts +21 -2
- package/dist/components/icon-picker.js +19 -4
- package/dist/components/icon-picker.js.map +1 -1
- package/dist/components/inline-edit-field.d.ts +167 -0
- package/dist/components/inline-edit-field.js +293 -0
- package/dist/components/inline-edit-field.js.map +1 -0
- package/dist/components/language-select.d.ts +130 -0
- package/dist/components/language-select.js +18 -0
- package/dist/components/language-select.js.map +1 -0
- package/dist/components/line-items.d.ts +25 -3
- package/dist/components/line-items.js +55 -46
- package/dist/components/line-items.js.map +1 -1
- package/dist/components/list.d.ts +25 -0
- package/dist/components/list.js +16 -5
- package/dist/components/list.js.map +1 -1
- package/dist/components/money-field.d.ts +32 -14
- package/dist/components/money-field.js.map +1 -1
- package/dist/components/month-picker.d.ts +52 -4
- package/dist/components/month-picker.js +291 -86
- package/dist/components/month-picker.js.map +1 -1
- package/dist/components/multi-entity-combobox.d.ts +26 -1
- package/dist/components/multi-entity-combobox.js +143 -102
- package/dist/components/multi-entity-combobox.js.map +1 -1
- package/dist/components/multi-select.d.ts +8 -1
- package/dist/components/multi-select.js +9 -4
- package/dist/components/multi-select.js.map +1 -1
- package/dist/components/number-field.d.ts +34 -19
- package/dist/components/number-field.js +6 -20
- package/dist/components/number-field.js.map +1 -1
- package/dist/components/number-input.d.ts +79 -19
- package/dist/components/number-input.js +90 -38
- package/dist/components/number-input.js.map +1 -1
- package/dist/components/numpad-sheet.d.ts +19 -14
- package/dist/components/one-time-code-input.d.ts +100 -0
- package/dist/components/one-time-code-input.js +137 -0
- package/dist/components/one-time-code-input.js.map +1 -0
- package/dist/components/phone-input.d.ts +69 -0
- package/dist/components/phone-input.js +171 -0
- package/dist/components/phone-input.js.map +1 -0
- package/dist/components/reauth-dialog.d.ts +2 -2
- package/dist/components/reauth-dialog.js +6 -8
- package/dist/components/reauth-dialog.js.map +1 -1
- package/dist/components/series-chart.d.ts +19 -14
- package/dist/components/settings-fields.d.ts +19 -14
- package/dist/components/share-card.d.ts +16 -11
- package/dist/components/sign-chip.d.ts +66 -0
- package/dist/components/sign-chip.js +39 -0
- package/dist/components/sign-chip.js.map +1 -0
- package/dist/components/swatch-picker.d.ts +43 -2
- package/dist/components/swatch-picker.js +80 -32
- package/dist/components/swatch-picker.js.map +1 -1
- package/dist/components/switch.d.ts +19 -0
- package/dist/components/switch.js +24 -6
- package/dist/components/switch.js.map +1 -1
- package/dist/components/text-link.d.ts +18 -13
- package/dist/components/tile-radio.d.ts +88 -13
- package/dist/components/tile-radio.js +28 -11
- package/dist/components/tile-radio.js.map +1 -1
- package/dist/components/time-input.d.ts +24 -15
- package/dist/components/time-input.js.map +1 -1
- package/dist/components/toggle-group.d.ts +47 -1
- package/dist/components/toggle-group.js +110 -69
- package/dist/components/toggle-group.js.map +1 -1
- package/dist/components/trigger-aria.d.ts +3 -0
- package/dist/components/trigger-aria.js +3 -1
- package/dist/components/trigger-aria.js.map +1 -1
- package/dist/components/ui.d.ts +16 -11
- package/dist/components/ui.js +284 -88
- package/dist/components/ui.js.map +1 -1
- package/dist/components/use-table-state.d.ts +1 -2
- package/dist/{data-table-3mi2R3so.d.ts → data-table-labels-B7OdnM0S.d.ts} +140 -3
- package/dist/data-table.d.ts +1 -2
- package/dist/data-table.js +2 -1
- package/dist/data-table.js.map +1 -1
- package/dist/feedback/feedback-attachment.d.ts +1 -1
- package/dist/feedback/feedback-attachment.js +348 -41
- package/dist/feedback/feedback-attachment.js.map +1 -1
- package/dist/feedback/feedback-dialog.d.ts +1 -1
- package/dist/feedback/feedback-dialog.js.map +1 -1
- package/dist/feedback/feedback-inbox.d.ts +3 -2
- package/dist/feedback/feedback-inbox.js.map +1 -1
- package/dist/feedback/feedback-thread.d.ts +68 -253
- package/dist/feedback/feedback-thread.js +46 -10
- package/dist/feedback/feedback-thread.js.map +1 -1
- package/dist/{kit-labels-D1J2_jyD.d.ts → feedback-BxeQVzwq.d.ts} +1206 -20
- package/dist/{feedback-attachment-WFiXJ8We.d.ts → feedback-attachment-fGAzZPf0.d.ts} +178 -11
- package/dist/feedback.d.ts +66 -2
- package/dist/hooks/use-file-drop.d.ts +19 -14
- package/dist/i18n/defaults.d.ts +19 -14
- package/dist/i18n/defaults.js +19 -2
- package/dist/i18n/defaults.js.map +1 -1
- package/dist/i18n/german.d.ts +19 -14
- package/dist/i18n/german.js +84 -5
- package/dist/i18n/german.js.map +1 -1
- package/dist/i18n/kit-labels.d.ts +15 -10
- package/dist/i18n/kit-labels.js +8 -2
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/languages.d.ts +19 -14
- package/dist/i18n/locales/de-CH.d.ts +19 -14
- package/dist/i18n/locales/en.d.ts +19 -14
- package/dist/i18n/locales/en.js +16 -0
- package/dist/i18n/locales/en.js.map +1 -1
- package/dist/i18n/locales/es.d.ts +19 -14
- package/dist/i18n/locales/es.js +83 -5
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.d.ts +19 -14
- package/dist/i18n/locales/fr.js +82 -5
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.d.ts +19 -14
- package/dist/i18n/locales/hu.js +85 -5
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.d.ts +19 -14
- package/dist/i18n/locales/it.js +84 -5
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.d.ts +19 -14
- package/dist/i18n/locales/zh.js +80 -5
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/i18n/review.d.ts +19 -14
- package/dist/i18n/review.js +27 -1
- package/dist/i18n/review.js.map +1 -1
- package/dist/index.d.ts +21 -10
- package/dist/index.js +73 -2
- package/dist/index.js.map +1 -1
- package/dist/lib/column-mapping.d.ts +81 -0
- package/dist/lib/column-mapping.js +108 -0
- package/dist/lib/column-mapping.js.map +1 -0
- package/dist/lib/countries.d.ts +43 -0
- package/dist/lib/countries.js +39 -0
- package/dist/lib/countries.js.map +1 -0
- package/dist/lib/decimal-marks.d.ts +46 -0
- package/dist/lib/decimal-marks.js +40 -0
- package/dist/lib/decimal-marks.js.map +1 -0
- package/dist/lib/iban.d.ts +98 -0
- package/dist/lib/iban.js +154 -0
- package/dist/lib/iban.js.map +1 -0
- package/dist/lib/isin.d.ts +30 -0
- package/dist/lib/isin.js +36 -0
- package/dist/lib/isin.js.map +1 -0
- package/dist/lib/phone.d.ts +69 -0
- package/dist/lib/phone.js +121 -0
- package/dist/lib/phone.js.map +1 -0
- package/dist/lib/table-text.d.ts +109 -1
- package/dist/lib/table-text.js +122 -1
- package/dist/lib/table-text.js.map +1 -1
- package/dist/rhf/fields.d.ts +233 -18
- package/dist/rhf/fields.js +456 -3
- package/dist/rhf/fields.js.map +1 -1
- package/dist/rhf/form.d.ts +19 -14
- package/dist/rhf.d.ts +22 -15
- package/dist/rhf.js.map +1 -1
- package/dist/shell/app-shell.d.ts +18 -13
- package/dist/shell/top-bar-brand.d.ts +19 -14
- package/dist/shell/topbar-action-menu.d.ts +36 -3
- package/dist/shell/topbar-action-menu.js +74 -33
- package/dist/shell/topbar-action-menu.js.map +1 -1
- package/dist/shell.d.ts +18 -13
- package/dist/table-text.d.ts +1 -1
- package/dist/tour/tour.js +20 -24
- package/dist/tour/tour.js.map +1 -1
- package/dist/wizard/stepper-nav.d.ts +49 -16
- package/dist/wizard/stepper-nav.js +4 -0
- package/dist/wizard/stepper-nav.js.map +1 -1
- package/dist/wizard.d.ts +19 -14
- package/package.json +1 -1
- package/src/components/account-chips.tsx +17 -3
- package/src/components/account-settings.tsx +92 -26
- package/src/components/amount-input.tsx +68 -56
- package/src/components/autocomplete.tsx +198 -114
- package/src/components/checkbox-group.tsx +229 -0
- package/src/components/checkbox.tsx +72 -8
- package/src/components/chip.tsx +80 -10
- package/src/components/column-mapper.tsx +666 -0
- package/src/components/combobox-core.tsx +82 -9
- package/src/components/combobox.tsx +661 -340
- package/src/components/confirm-dialog.tsx +12 -8
- package/src/components/country-select.tsx +556 -0
- package/src/components/currency-select.tsx +109 -33
- package/src/components/danger-confirm.tsx +429 -82
- package/src/components/data-table-labels.ts +16 -1
- package/src/components/data-table.tsx +175 -6
- package/src/components/date-picker.tsx +595 -71
- package/src/components/entity-combobox.tsx +235 -124
- package/src/components/field-parts.tsx +403 -0
- package/src/components/field-strip.tsx +149 -0
- package/src/components/file-button.tsx +36 -3
- package/src/components/file-dropzone.tsx +106 -38
- package/src/components/form-actions.tsx +147 -6
- package/src/components/iban-input.tsx +231 -0
- package/src/components/icon-picker.tsx +47 -6
- package/src/components/inline-edit-field.tsx +533 -0
- package/src/components/language-select.tsx +78 -0
- package/src/components/line-items.tsx +35 -2
- package/src/components/list.tsx +55 -6
- package/src/components/money-field.tsx +13 -0
- package/src/components/month-picker.tsx +400 -63
- package/src/components/multi-entity-combobox.tsx +207 -120
- package/src/components/multi-select.tsx +80 -58
- package/src/components/number-field.tsx +28 -46
- package/src/components/number-input.tsx +210 -43
- package/src/components/one-time-code-input.tsx +288 -0
- package/src/components/phone-input.tsx +299 -0
- package/src/components/reauth-dialog.tsx +17 -18
- package/src/components/sign-chip.tsx +103 -0
- package/src/components/swatch-picker.tsx +143 -31
- package/src/components/switch.tsx +58 -7
- package/src/components/tile-radio.tsx +145 -29
- package/src/components/time-input.tsx +8 -1
- package/src/components/toggle-group.tsx +206 -73
- package/src/components/trigger-aria.ts +5 -0
- package/src/components/ui.tsx +479 -138
- package/src/data-table.ts +2 -1
- package/src/feedback/feedback-attachment.tsx +597 -46
- package/src/feedback/feedback-dialog.tsx +9 -3
- package/src/feedback/feedback-inbox.tsx +3 -2
- package/src/feedback/feedback-thread.tsx +113 -9
- package/src/i18n/defaults.ts +18 -1
- package/src/i18n/german.ts +89 -0
- package/src/i18n/kit-labels.tsx +82 -2
- package/src/i18n/locales/en.ts +36 -4
- package/src/i18n/locales/es.ts +87 -0
- package/src/i18n/locales/fr.ts +87 -0
- package/src/i18n/locales/hu.ts +83 -0
- package/src/i18n/locales/it.ts +88 -0
- package/src/i18n/locales/zh.ts +76 -0
- package/src/i18n/review.ts +26 -0
- package/src/index.ts +71 -3
- package/src/lib/column-mapping.ts +234 -0
- package/src/lib/countries.ts +91 -0
- package/src/lib/decimal-marks.ts +99 -0
- package/src/lib/iban.ts +171 -0
- package/src/lib/isin.ts +62 -0
- package/src/lib/phone.ts +211 -0
- package/src/lib/table-text.ts +271 -0
- package/src/rhf/fields.tsx +860 -9
- package/src/rhf.ts +3 -1
- package/src/shell/topbar-action-menu.tsx +134 -38
- package/src/tour/tour.tsx +66 -28
- package/src/wizard/stepper-nav.tsx +35 -1
package/src/components/ui.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createContext, forwardRef, useContext, useId, useLayoutEffect, useRef, useState } from "react";
|
|
2
2
|
import { ChevronDown, Eye, EyeOff, HelpCircle, Plus, X } from "lucide-react";
|
|
3
|
-
import type { AnchorHTMLAttributes, ButtonHTMLAttributes, ComponentPropsWithoutRef, CSSProperties, InputHTMLAttributes, KeyboardEvent, MouseEvent, ReactElement, ReactNode, Ref, SelectHTMLAttributes, TextareaHTMLAttributes } from "react";
|
|
3
|
+
import type { AnchorHTMLAttributes, ButtonHTMLAttributes, ChangeEvent, ComponentPropsWithoutRef, CSSProperties, InputHTMLAttributes, KeyboardEvent, MouseEvent, ReactElement, ReactNode, Ref, SelectHTMLAttributes, TextareaHTMLAttributes } from "react";
|
|
4
4
|
import { cn } from "../lib/cn";
|
|
5
5
|
import { scrollIntoStrip, useStripFade } from "../lib/strip-fade";
|
|
6
6
|
import { horizontalStep } from "../lib/direction";
|
|
@@ -10,6 +10,22 @@ import { DEFAULT_COMMON_LABELS, useKitLabels, useKitLink } from "../i18n/kit-lab
|
|
|
10
10
|
import type { KitLinkComponent, KitLinkProps } from "../i18n/kit-labels";
|
|
11
11
|
import { pickLinkRenderer, replacingClick, routerLinkNavigation } from "./text-link";
|
|
12
12
|
import { useCommitReason } from "./write-lock";
|
|
13
|
+
import { mergeDescribedBy } from "./choice-parts";
|
|
14
|
+
import {
|
|
15
|
+
EndHintRow,
|
|
16
|
+
FieldCaption,
|
|
17
|
+
LockedReason,
|
|
18
|
+
useFieldHint,
|
|
19
|
+
useLockReason,
|
|
20
|
+
FLOATING_LABEL_STATIC,
|
|
21
|
+
STATIC_LABEL_TYPE,
|
|
22
|
+
type FieldHintParts,
|
|
23
|
+
} from "./field-parts";
|
|
24
|
+
|
|
25
|
+
// The static label lives in field-parts since 0.23 (it is what the field anatomy
|
|
26
|
+
// there draws, and field-parts must not import this file); public from here as before.
|
|
27
|
+
export { FieldLabel, FLOATING_LABEL_STATIC } from "./field-parts";
|
|
28
|
+
export type { FieldLabelProps } from "./field-parts";
|
|
13
29
|
|
|
14
30
|
export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger" | "brand" | "link";
|
|
15
31
|
|
|
@@ -1474,22 +1490,6 @@ const FLOATING_ROW_CLASS = cn(
|
|
|
1474
1490
|
"peer-disabled:opacity-50",
|
|
1475
1491
|
);
|
|
1476
1492
|
|
|
1477
|
-
// The TYPE of the small static label, without any placement. Split out so the
|
|
1478
|
-
// label and anything sharing its line (see `hint` on {@link FloatingField}) are
|
|
1479
|
-
// laid out by one flex row instead of by two absolute offsets guessing at the
|
|
1480
|
-
// same baseline — which is what put dev#468's "?" three pixels above the word it
|
|
1481
|
-
// belongs to.
|
|
1482
|
-
const STATIC_LABEL_TYPE =
|
|
1483
|
-
"text-[11px] leading-tight text-[var(--text-muted)] peer-disabled:opacity-50";
|
|
1484
|
-
|
|
1485
|
-
// A field that always has a value (select / dropdown trigger) keeps the label
|
|
1486
|
-
// permanently in the floated position — small, in the top strip, value below.
|
|
1487
|
-
export const FLOATING_LABEL_STATIC = cn(
|
|
1488
|
-
"pointer-events-none absolute start-3 top-1",
|
|
1489
|
-
STATIC_LABEL_TYPE,
|
|
1490
|
-
"max-w-[calc(100%-1.5rem)] truncate",
|
|
1491
|
-
);
|
|
1492
|
-
|
|
1493
1493
|
/**
|
|
1494
1494
|
* The one place that assembles a labelled field: a `relative` wrapper around the
|
|
1495
1495
|
* control (`children`) plus a floating label inside the top strip. Pass `staticLabel`
|
|
@@ -1641,23 +1641,6 @@ export function FieldHint({
|
|
|
1641
1641
|
);
|
|
1642
1642
|
}
|
|
1643
1643
|
|
|
1644
|
-
/**
|
|
1645
|
-
* The floating label for a custom-dropdown trigger (a `<span>`, since the trigger is a
|
|
1646
|
-
* button not a labelable input). Same placement as {@link FloatingField}'s static label,
|
|
1647
|
-
* so every labelled field lines up. Render inside a `relative` wrapper, before the trigger.
|
|
1648
|
-
*/
|
|
1649
|
-
export interface FieldLabelProps extends ComponentPropsWithoutRef<"span"> {
|
|
1650
|
-
children: ReactNode;
|
|
1651
|
-
}
|
|
1652
|
-
|
|
1653
|
-
export function FieldLabel({ children, className, ...rest }: FieldLabelProps) {
|
|
1654
|
-
return (
|
|
1655
|
-
<span {...rest} className={cn(FLOATING_LABEL_STATIC, "z-10", className)}>
|
|
1656
|
-
{children}
|
|
1657
|
-
</span>
|
|
1658
|
-
);
|
|
1659
|
-
}
|
|
1660
|
-
|
|
1661
1644
|
export interface LabelProps extends ComponentPropsWithoutRef<"label"> {
|
|
1662
1645
|
/** Draw the required mark after the text. The mark is `aria-hidden`: what a
|
|
1663
1646
|
* screen reader hears is the control's own `required` / `aria-required`, which
|
|
@@ -1840,6 +1823,149 @@ export function resolvePasswordRevealLabels(
|
|
|
1840
1823
|
return { ...DEFAULT_PASSWORD_REVEAL_LABELS, ...partial };
|
|
1841
1824
|
}
|
|
1842
1825
|
|
|
1826
|
+
/**
|
|
1827
|
+
* What the character counter of {@link Input} and {@link Textarea} (`showCount`) says.
|
|
1828
|
+
* The visible "12/80" is digits and a slash and is not translated; these are the words
|
|
1829
|
+
* a screen reader gets instead of it.
|
|
1830
|
+
*/
|
|
1831
|
+
export interface CharacterCountLabels {
|
|
1832
|
+
/** The counter as part of the field's description, read when focus reaches the
|
|
1833
|
+
* field: "12 of 80 characters". */
|
|
1834
|
+
count: (used: number, max: number) => string;
|
|
1835
|
+
/** Said once, politely, on entering the last stretch before the limit: "8
|
|
1836
|
+
* characters left". */
|
|
1837
|
+
remaining: (left: number) => string;
|
|
1838
|
+
/** Said once, politely, on reaching the limit. */
|
|
1839
|
+
limitReached: string;
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1842
|
+
export const DEFAULT_CHARACTER_COUNT_LABELS: CharacterCountLabels = {
|
|
1843
|
+
count: (used, max) => `${used} of ${max} characters`,
|
|
1844
|
+
remaining: (left) => (left === 1 ? "1 character left" : `${left} characters left`),
|
|
1845
|
+
limitReached: "Character limit reached",
|
|
1846
|
+
};
|
|
1847
|
+
|
|
1848
|
+
/** The length the browser's own `maxLength` counts — UTF-16 code units — so the counter
|
|
1849
|
+
* and the limit can never disagree about whether a field is full. */
|
|
1850
|
+
function textLength(value: unknown): number {
|
|
1851
|
+
return value === undefined || value === null ? 0 : String(value).length;
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1854
|
+
/** The counter's length: the controlled `value`'s, else what the field has been typed
|
|
1855
|
+
* to (from `defaultValue` on). `track` is called from the field's change handler. */
|
|
1856
|
+
function useTypedLength(value: unknown, defaultValue: unknown) {
|
|
1857
|
+
const [typed, setTyped] = useState(() => textLength(defaultValue));
|
|
1858
|
+
return { used: value !== undefined ? textLength(value) : typed, track: (text: string) => setTyped(text.length) };
|
|
1859
|
+
}
|
|
1860
|
+
|
|
1861
|
+
type CountZone = "ok" | "near" | "limit";
|
|
1862
|
+
|
|
1863
|
+
/** The last stretch before the limit: a tenth of it, at least 1 and at most 20 — "8
|
|
1864
|
+
* left" of 80, and 20 of 2,000 rather than 200, which is not "near" in any sense a
|
|
1865
|
+
* person typing means. */
|
|
1866
|
+
function nearLimitAt(max: number): number {
|
|
1867
|
+
return Math.min(20, Math.max(1, Math.ceil(max * 0.1)));
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
function countZone(used: number, max: number): CountZone {
|
|
1871
|
+
if (used >= max) return "limit";
|
|
1872
|
+
return max - used <= nearLimitAt(max) ? "near" : "ok";
|
|
1873
|
+
}
|
|
1874
|
+
|
|
1875
|
+
/**
|
|
1876
|
+
* The live "12/80" under a field with `showCount` (keksdose K11: the admin broadcast,
|
|
1877
|
+
* support replies, canned replies and the handoff note each counted by hand, and one
|
|
1878
|
+
* announced every keystroke).
|
|
1879
|
+
*
|
|
1880
|
+
* Three channels, each saying only what it should:
|
|
1881
|
+
* - the digits, `aria-hidden` — "12 slash 80" is not a sentence;
|
|
1882
|
+
* - a `hidden` "12 of 80 characters" on the field's `aria-describedby`, so the count is
|
|
1883
|
+
* read with the field when focus reaches it, and never while typing;
|
|
1884
|
+
* - a polite live region that speaks ONCE per stretch: on entering the last tenth
|
|
1885
|
+
* ("8 characters left") and on reaching the limit. A region that changed on every
|
|
1886
|
+
* keystroke would talk over the user's own typing echo for the whole message.
|
|
1887
|
+
* Nothing is said on mount: a stored note already near its limit is not news.
|
|
1888
|
+
*/
|
|
1889
|
+
function CharacterCount({
|
|
1890
|
+
id,
|
|
1891
|
+
used,
|
|
1892
|
+
max,
|
|
1893
|
+
labels,
|
|
1894
|
+
caption,
|
|
1895
|
+
}: {
|
|
1896
|
+
id: string;
|
|
1897
|
+
used: number;
|
|
1898
|
+
max: number;
|
|
1899
|
+
labels: CharacterCountLabels;
|
|
1900
|
+
caption: ReactNode;
|
|
1901
|
+
}) {
|
|
1902
|
+
const zone = countZone(used, max);
|
|
1903
|
+
// What the live region holds, re-set only when the stretch changes — the previous
|
|
1904
|
+
// render's zone kept in state, the pattern React documents for "adjusting state when
|
|
1905
|
+
// a prop changes", so the region's text (and so the announcement) changes at most
|
|
1906
|
+
// once per crossing.
|
|
1907
|
+
const [said, setSaid] = useState<{ zone: CountZone; text: string }>({ zone, text: "" });
|
|
1908
|
+
if (said.zone !== zone) {
|
|
1909
|
+
setSaid({
|
|
1910
|
+
zone,
|
|
1911
|
+
text: zone === "near" ? labels.remaining(max - used) : zone === "limit" ? labels.limitReached : "",
|
|
1912
|
+
});
|
|
1913
|
+
}
|
|
1914
|
+
return (
|
|
1915
|
+
// `relative` holds the sr-only region (see sr-only-containment.test).
|
|
1916
|
+
<div className="relative mt-1 flex items-start gap-2">
|
|
1917
|
+
<div className="min-w-0 flex-1">{caption}</div>
|
|
1918
|
+
<span
|
|
1919
|
+
aria-hidden
|
|
1920
|
+
className={cn(
|
|
1921
|
+
"shrink-0 text-[11px] leading-tight tabular-nums",
|
|
1922
|
+
zone === "limit"
|
|
1923
|
+
? "text-[var(--danger)]"
|
|
1924
|
+
: zone === "near"
|
|
1925
|
+
? "text-[var(--warning)]"
|
|
1926
|
+
: "text-[var(--text-muted)]",
|
|
1927
|
+
)}
|
|
1928
|
+
>
|
|
1929
|
+
{used}/{max}
|
|
1930
|
+
</span>
|
|
1931
|
+
<span id={id} hidden>
|
|
1932
|
+
{labels.count(used, max)}
|
|
1933
|
+
</span>
|
|
1934
|
+
<span role="status" className="sr-only">
|
|
1935
|
+
{said.text}
|
|
1936
|
+
</span>
|
|
1937
|
+
</div>
|
|
1938
|
+
);
|
|
1939
|
+
}
|
|
1940
|
+
|
|
1941
|
+
/** What goes under a text field: the caption (beside the counter, when there is one),
|
|
1942
|
+
* then the error. `null` when there is nothing, so a field with none keeps exactly the
|
|
1943
|
+
* DOM it had (see {@link FieldGroup}). */
|
|
1944
|
+
function fieldBelow(hint: FieldHintParts, counter: ReactNode, errorEl: ReactNode): ReactNode {
|
|
1945
|
+
if (counter === null && hint.captionText === undefined && errorEl === null) return null;
|
|
1946
|
+
return (
|
|
1947
|
+
<>
|
|
1948
|
+
{counter ?? <FieldCaption parts={hint} />}
|
|
1949
|
+
{errorEl}
|
|
1950
|
+
</>
|
|
1951
|
+
);
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1954
|
+
/**
|
|
1955
|
+
* kastlan 8: a caller's placeholder on a LABELLED field, shown once the label has
|
|
1956
|
+
* floated out of its way — on focus — and transparent until then, while the label sits
|
|
1957
|
+
* where the placeholder would be. Without it the field forced `placeholder=" "` for the
|
|
1958
|
+
* float trick and dropped the caller's text without a word (kastlan's "note" field lost
|
|
1959
|
+
* its hint). `:placeholder-shown` still drives the float: it matches an empty field
|
|
1960
|
+
* whatever the placeholder's text is.
|
|
1961
|
+
*/
|
|
1962
|
+
const FLOATING_PLACEHOLDER_ON_FOCUS = "focus:placeholder:text-[var(--text-placeholder)]";
|
|
1963
|
+
|
|
1964
|
+
/** A placeholder with something to show. `""` and blanks are the float trick's own
|
|
1965
|
+
* `" "`, never the caller's hint. */
|
|
1966
|
+
const hasPlaceholderText = (placeholder: string | undefined): placeholder is string =>
|
|
1967
|
+
placeholder !== undefined && placeholder.trim() !== "";
|
|
1968
|
+
|
|
1843
1969
|
// Native date/time inputs only reveal the calendar via the tiny trailing icon;
|
|
1844
1970
|
// open the picker on a click anywhere in the field instead (feedback #224).
|
|
1845
1971
|
const PICKER_TYPES = new Set(["date", "datetime-local", "month", "time", "week"]);
|
|
@@ -1874,12 +2000,42 @@ export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
|
|
1874
2000
|
* `invalid`, so the field paints as well as announces. `invalid` alone still
|
|
1875
2001
|
* covers the case where the message lives elsewhere. See {@link useFieldError}. */
|
|
1876
2002
|
error?: ReactNode;
|
|
2003
|
+
/**
|
|
2004
|
+
* Standing advice for the field (keksdose K4: Input had `error` but no `hint`, so the
|
|
2005
|
+
* app built the caption by hand under each field, and half of those never reached a
|
|
2006
|
+
* screen reader). The {@link Select} rule, so every field reads alike:
|
|
2007
|
+
* - plain TEXT (a string or a number) is a caption UNDER the field, attached with
|
|
2008
|
+
* `aria-describedby` — after the caller's own ids, before the error's;
|
|
2009
|
+
* - anything else (a {@link FieldHint} "?") rides the label line beside the
|
|
2010
|
+
* animated label, as NumberInput's does — or, on a field with no label, sits at the
|
|
2011
|
+
* field's end edge outside the box, and `className` then styles that row.
|
|
2012
|
+
*/
|
|
2013
|
+
hint?: ReactNode;
|
|
2014
|
+
/**
|
|
2015
|
+
* A live "12/80" under the field, with `maxLength` (keksdose K11 — the broadcast,
|
|
2016
|
+
* support and handoff-note fields counted by hand). Read with the field as "12 of 80
|
|
2017
|
+
* characters", and announced politely only on entering the last stretch and on
|
|
2018
|
+
* reaching the limit — never on every keystroke. See {@link CharacterCount}. Ignored
|
|
2019
|
+
* without a `maxLength`: a count with no limit is not what this answers. Counts
|
|
2020
|
+
* UTF-16 code units, as `maxLength` itself does. Controlled, it counts `value`;
|
|
2021
|
+
* uncontrolled, `defaultValue` and then every change event — a value written from
|
|
2022
|
+
* outside without one (a form library's `reset`) shows from the next keystroke.
|
|
2023
|
+
*/
|
|
2024
|
+
showCount?: boolean;
|
|
2025
|
+
/** The counter's words. See {@link CharacterCountLabels}. */
|
|
2026
|
+
countLabels?: Partial<CharacterCountLabels>;
|
|
1877
2027
|
/** Names for the password reveal toggle, English by default — it is the one
|
|
1878
2028
|
* string this component renders on its own behalf, and a German form was
|
|
1879
2029
|
* reading it out in English. See {@link PasswordRevealLabels}. */
|
|
1880
2030
|
passwordLabels?: Partial<PasswordRevealLabels>;
|
|
1881
2031
|
}
|
|
1882
2032
|
|
|
2033
|
+
/** The counter's words: the `characterCount` namespace — prop > `<UiKitProvider
|
|
2034
|
+
* labels>` > English. One place, so the namespace is named once. */
|
|
2035
|
+
function useCharacterCountLabels(prop: Partial<CharacterCountLabels> | undefined): CharacterCountLabels {
|
|
2036
|
+
return useKitLabels("characterCount", DEFAULT_CHARACTER_COUNT_LABELS, prop);
|
|
2037
|
+
}
|
|
2038
|
+
|
|
1883
2039
|
export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(props, ref) {
|
|
1884
2040
|
const {
|
|
1885
2041
|
className,
|
|
@@ -1891,19 +2047,47 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(pro
|
|
|
1891
2047
|
variant = "field",
|
|
1892
2048
|
invalid,
|
|
1893
2049
|
error,
|
|
2050
|
+
hint,
|
|
2051
|
+
showCount,
|
|
2052
|
+
countLabels,
|
|
1894
2053
|
passwordLabels,
|
|
1895
2054
|
...rest
|
|
1896
2055
|
} = props;
|
|
1897
|
-
// Passed at all — even as `undefined` — the field keeps its box; see FieldGroup.
|
|
1898
|
-
|
|
2056
|
+
// Passed at all — even as `undefined` — the field keeps its box; see FieldGroup. The
|
|
2057
|
+
// same for `hint` and `showCount` (keksdose, 0.22): a caption coming and going under
|
|
2058
|
+
// the control must not rebuild it, or focus and the caret are lost mid-edit.
|
|
2059
|
+
const reserve = "error" in props || "hint" in props || "showCount" in props;
|
|
1899
2060
|
const generated = useId();
|
|
1900
2061
|
const fieldId = id ?? generated;
|
|
2062
|
+
const countId = useId();
|
|
2063
|
+
const hintParts = useFieldHint(hint, rest["aria-describedby"]);
|
|
2064
|
+
const maxLength = rest.maxLength;
|
|
2065
|
+
const counting = Boolean(showCount) && typeof maxLength === "number" && maxLength > 0;
|
|
2066
|
+
const length = useTypedLength(rest.value, rest.defaultValue);
|
|
2067
|
+
const countText = useCharacterCountLabels(countLabels);
|
|
1901
2068
|
const { isInvalid, describedBy, errorEl } = useFieldError(
|
|
1902
2069
|
error,
|
|
1903
2070
|
invalid,
|
|
1904
|
-
|
|
2071
|
+
// Caller's, caption, count — then the error, appended by the hook.
|
|
2072
|
+
mergeDescribedBy(hintParts.describedBy, counting && countId),
|
|
1905
2073
|
rest["aria-invalid"],
|
|
1906
2074
|
);
|
|
2075
|
+
const counter = counting ? (
|
|
2076
|
+
<CharacterCount
|
|
2077
|
+
id={countId}
|
|
2078
|
+
used={length.used}
|
|
2079
|
+
max={maxLength}
|
|
2080
|
+
labels={countText}
|
|
2081
|
+
caption={<FieldCaption parts={hintParts} className="mt-0" />}
|
|
2082
|
+
/>
|
|
2083
|
+
) : null;
|
|
2084
|
+
const below = fieldBelow(hintParts, counter, errorEl);
|
|
2085
|
+
const onChange = counting
|
|
2086
|
+
? (e: ChangeEvent<HTMLInputElement>) => {
|
|
2087
|
+
length.track(e.target.value);
|
|
2088
|
+
rest.onChange?.(e);
|
|
2089
|
+
}
|
|
2090
|
+
: rest.onChange;
|
|
1907
2091
|
const asDisplay = useMediaQuery(PHONE_QUERY, false) && variant === "display";
|
|
1908
2092
|
// Password fields get a reveal toggle so users can check what they typed.
|
|
1909
2093
|
const isPassword = type === "password";
|
|
@@ -1943,66 +2127,92 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(pro
|
|
|
1943
2127
|
</button>
|
|
1944
2128
|
) : null;
|
|
1945
2129
|
if (label === undefined) {
|
|
2130
|
+
// A "?" with no label line to ride goes at the end edge, outside the box, and
|
|
2131
|
+
// `className` moves to that row (see `hint`); without one nothing moves.
|
|
2132
|
+
const endHint = hintParts.labelHint;
|
|
2133
|
+
const ownClass = endHint === undefined ? className : undefined;
|
|
1946
2134
|
if (!isPassword) {
|
|
1947
2135
|
return (
|
|
1948
|
-
<FieldGroup errorEl={
|
|
1949
|
-
<
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
2136
|
+
<FieldGroup errorEl={below} reserve={reserve}>
|
|
2137
|
+
<EndHintRow hint={endHint} className={className}>
|
|
2138
|
+
<input
|
|
2139
|
+
ref={ref}
|
|
2140
|
+
id={id}
|
|
2141
|
+
type={type}
|
|
2142
|
+
placeholder={placeholder}
|
|
2143
|
+
{...rest}
|
|
2144
|
+
// AFTER the spread, so the prop wins — but OR-ed with whatever the spread
|
|
2145
|
+
// carried, or setting `invalid` would have quietly deleted a caller's own
|
|
2146
|
+
// `aria-invalid`. The prop is the one that also paints; a bare attribute
|
|
2147
|
+
// still announces, which is all it ever did.
|
|
2148
|
+
aria-invalid={isInvalid || rest["aria-invalid"] || undefined}
|
|
2149
|
+
// Likewise merged rather than replaced — see {@link useFieldError}.
|
|
2150
|
+
aria-describedby={describedBy}
|
|
2151
|
+
onClick={handleClick}
|
|
2152
|
+
onChange={onChange}
|
|
2153
|
+
className={cn(FIELD_BASE, ownClass, inputClassName, isInvalid && FIELD_INVALID)}
|
|
2154
|
+
/>
|
|
2155
|
+
</EndHintRow>
|
|
1965
2156
|
</FieldGroup>
|
|
1966
2157
|
);
|
|
1967
2158
|
}
|
|
1968
2159
|
return (
|
|
1969
|
-
<FieldGroup errorEl={
|
|
1970
|
-
<
|
|
1971
|
-
<
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
2160
|
+
<FieldGroup errorEl={below} reserve={reserve}>
|
|
2161
|
+
<EndHintRow hint={endHint} className={className}>
|
|
2162
|
+
<div className={cn("relative", ownClass)}>
|
|
2163
|
+
<input
|
|
2164
|
+
ref={ref}
|
|
2165
|
+
id={id}
|
|
2166
|
+
type={effectiveType}
|
|
2167
|
+
placeholder={placeholder}
|
|
2168
|
+
{...rest}
|
|
2169
|
+
aria-invalid={isInvalid || rest["aria-invalid"] || undefined}
|
|
2170
|
+
aria-describedby={describedBy}
|
|
2171
|
+
onChange={onChange}
|
|
2172
|
+
className={cn(FIELD_BASE, "pe-9", inputClassName, isInvalid && FIELD_INVALID)}
|
|
2173
|
+
/>
|
|
2174
|
+
{revealToggle}
|
|
2175
|
+
</div>
|
|
2176
|
+
</EndHintRow>
|
|
1983
2177
|
</FieldGroup>
|
|
1984
2178
|
);
|
|
1985
2179
|
}
|
|
2180
|
+
const ownPlaceholder = hasPlaceholderText(placeholder);
|
|
1986
2181
|
return (
|
|
1987
|
-
<FieldGroup errorEl={
|
|
1988
|
-
<FloatingField
|
|
2182
|
+
<FieldGroup errorEl={below} reserve={reserve}>
|
|
2183
|
+
<FloatingField
|
|
2184
|
+
className={className}
|
|
2185
|
+
htmlFor={fieldId}
|
|
2186
|
+
label={label}
|
|
2187
|
+
srOnlyLabel={asDisplay}
|
|
2188
|
+
hint={hintParts.labelHint}
|
|
2189
|
+
>
|
|
1989
2190
|
<input
|
|
1990
2191
|
ref={ref}
|
|
1991
2192
|
id={fieldId}
|
|
1992
2193
|
type={effectiveType}
|
|
1993
|
-
// A labelled field
|
|
1994
|
-
// floating label's peer-placeholder-shown trick.
|
|
1995
|
-
//
|
|
1996
|
-
//
|
|
1997
|
-
|
|
2194
|
+
// A labelled field with no placeholder of its own gets a single space, which
|
|
2195
|
+
// feeds the floating label's peer-placeholder-shown trick. A caller's own is
|
|
2196
|
+
// kept and shown on focus, once the label has floated out of its way (kastlan
|
|
2197
|
+
// 8) — see FLOATING_PLACEHOLDER_ON_FOCUS. With the label sr-only there is no
|
|
2198
|
+
// float left to drive, and an empty borderless line would say nothing at all
|
|
2199
|
+
// — so there the label text is the placeholder when the caller gave none.
|
|
2200
|
+
placeholder={
|
|
2201
|
+
asDisplay
|
|
2202
|
+
? (placeholder ?? (typeof label === "string" ? label : " "))
|
|
2203
|
+
: ownPlaceholder
|
|
2204
|
+
? placeholder
|
|
2205
|
+
: " "
|
|
2206
|
+
}
|
|
1998
2207
|
{...rest}
|
|
1999
2208
|
aria-invalid={isInvalid || rest["aria-invalid"] || undefined}
|
|
2000
2209
|
aria-describedby={describedBy}
|
|
2001
2210
|
onClick={handleClick}
|
|
2211
|
+
onChange={onChange}
|
|
2002
2212
|
className={cn(
|
|
2003
2213
|
asDisplay
|
|
2004
2214
|
? cn(FIELD_DISPLAY, "text-xl font-semibold leading-snug")
|
|
2005
|
-
: FLOATING_INPUT_CLASS,
|
|
2215
|
+
: cn(FLOATING_INPUT_CLASS, ownPlaceholder && FLOATING_PLACEHOLDER_ON_FOCUS),
|
|
2006
2216
|
isPassword && "pe-9",
|
|
2007
2217
|
inputClassName,
|
|
2008
2218
|
isInvalid && FIELD_INVALID,
|
|
@@ -2045,6 +2255,29 @@ export interface SelectProps extends Omit<SelectHTMLAttributes<HTMLSelectElement
|
|
|
2045
2255
|
* the chevron is positioned against — so it could set a width and nothing
|
|
2046
2256
|
* else; this is the way to the element. See {@link Input}'s `inputClassName`. */
|
|
2047
2257
|
selectClassName?: string;
|
|
2258
|
+
/**
|
|
2259
|
+
* Why the choice cannot be changed — {@link Button}'s `disabledReason`, for a select
|
|
2260
|
+
* that SAVES on change (a role picker in a members table, a status in a row). keksdose
|
|
2261
|
+
* K3: those sat inside a hand-rolled `SaveGuard` that forced a native `disabled`, which
|
|
2262
|
+
* took the field out of the tab order so the reason never reached a keyboard.
|
|
2263
|
+
*
|
|
2264
|
+
* With a reason the select is `aria-disabled` instead — still focusable, still showing
|
|
2265
|
+
* its value — its list does not open (pointer and keys are swallowed, Tab and Escape
|
|
2266
|
+
* excepted), `onChange` is never called, and the reason is in the kit {@link Tooltip}
|
|
2267
|
+
* and on `aria-describedby`. It wears the settled look of a disabled field and drops
|
|
2268
|
+
* its chevron, as a disabled Select does (dev#474). It wins over `disabled`.
|
|
2269
|
+
*
|
|
2270
|
+
* Use it CONTROLLED (`value` + `onChange`): React puts a controlled value back after
|
|
2271
|
+
* the swallowed change. A touch platform's own picker may still open; nothing it picks
|
|
2272
|
+
* is kept or reported.
|
|
2273
|
+
*/
|
|
2274
|
+
disabledReason?: ReactNode;
|
|
2275
|
+
/**
|
|
2276
|
+
* This select COMMITS — choosing saves. Under a locked {@link WriteLockProvider} it is
|
|
2277
|
+
* locked the `disabledReason` way with the lock's reason (which wins over its own).
|
|
2278
|
+
* No provider, or an unlocked one: no effect. Button's `commit`, for keksdose K3.
|
|
2279
|
+
*/
|
|
2280
|
+
commit?: boolean;
|
|
2048
2281
|
}
|
|
2049
2282
|
|
|
2050
2283
|
// The compact select: the field's colours, a toolbar button's height. `py-0` and a
|
|
@@ -2052,12 +2285,37 @@ export interface SelectProps extends Omit<SelectHTMLAttributes<HTMLSelectElement
|
|
|
2052
2285
|
// height the caller's type brings with it.
|
|
2053
2286
|
const SELECT_SM = "h-7 py-0 ps-2 pe-7 text-xs";
|
|
2054
2287
|
|
|
2288
|
+
/** Keys a locked select still answers: leaving it, and dismissing its tooltip. Every
|
|
2289
|
+
* other key would open the list or step the value. */
|
|
2290
|
+
const LOCKED_SELECT_KEYS = new Set(["Tab", "Escape", "Shift"]);
|
|
2291
|
+
|
|
2292
|
+
/** FIELD_BASE's `disabled:` look, for a select that is locked without being `disabled`
|
|
2293
|
+
* (the `:disabled` variants cannot match it). */
|
|
2294
|
+
const FIELD_LOCKED = "cursor-not-allowed bg-[var(--bg-surface-2)] text-[var(--text-muted)]";
|
|
2295
|
+
|
|
2055
2296
|
export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select(props, ref) {
|
|
2056
|
-
const {
|
|
2057
|
-
|
|
2297
|
+
const {
|
|
2298
|
+
className,
|
|
2299
|
+
label,
|
|
2300
|
+
id,
|
|
2301
|
+
children,
|
|
2302
|
+
invalid,
|
|
2303
|
+
error,
|
|
2304
|
+
hint,
|
|
2305
|
+
size,
|
|
2306
|
+
selectClassName,
|
|
2307
|
+
disabledReason,
|
|
2308
|
+
commit,
|
|
2309
|
+
onMouseDown,
|
|
2310
|
+
onKeyDown,
|
|
2311
|
+
onChange,
|
|
2312
|
+
...rest
|
|
2313
|
+
} = props;
|
|
2058
2314
|
// See Input's `reserve`.
|
|
2059
|
-
const reserve = "error" in props;
|
|
2315
|
+
const reserve = "error" in props || "hint" in props || "showCount" in props;
|
|
2060
2316
|
const generated = useId();
|
|
2317
|
+
const lock = useLockReason(commit, disabledReason);
|
|
2318
|
+
const locked = lock.locked;
|
|
2061
2319
|
// Only a number reaches the DOM; the two words are this component's own.
|
|
2062
2320
|
const nativeSize = typeof size === "number" ? size : undefined;
|
|
2063
2321
|
// A numeric size above 1 (or `multiple`) makes the browser draw a LIST BOX — all
|
|
@@ -2076,12 +2334,9 @@ export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select
|
|
|
2076
2334
|
error,
|
|
2077
2335
|
invalid,
|
|
2078
2336
|
// The caption goes in BEFORE the error — the standing advice first, the news
|
|
2079
|
-
// second, the order useFieldError keeps for a caller's own description.
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
? `${rest["aria-describedby"]} ${hintId}`
|
|
2083
|
-
: hintId
|
|
2084
|
-
: rest["aria-describedby"],
|
|
2337
|
+
// second, the order useFieldError keeps for a caller's own description. A lock's
|
|
2338
|
+
// reason sits between the two.
|
|
2339
|
+
mergeDescribedBy(rest["aria-describedby"], textHint && hintId, locked && lock.reasonId),
|
|
2085
2340
|
rest["aria-invalid"],
|
|
2086
2341
|
);
|
|
2087
2342
|
const below =
|
|
@@ -2099,9 +2354,10 @@ export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select
|
|
|
2099
2354
|
// in from the end border and matches both themes — feedback #223. A DISABLED
|
|
2100
2355
|
// select has no menu to drop, so it drops the chevron too: the arrow is the one
|
|
2101
2356
|
// thing on the control that promises a choice (Keksdose dev#474, where the
|
|
2102
|
-
// account type became read-only and still looked exactly like a picker).
|
|
2357
|
+
// account type became read-only and still looked exactly like a picker). A locked
|
|
2358
|
+
// one has no menu either.
|
|
2103
2359
|
const chevron =
|
|
2104
|
-
rest.disabled || listBox ? null : (
|
|
2360
|
+
rest.disabled || listBox || locked ? null : (
|
|
2105
2361
|
<FieldChevron className={small ? "end-1.5 size-3.5" : undefined} />
|
|
2106
2362
|
);
|
|
2107
2363
|
// `pe-9` is the room the chevron takes; a list box has none to make room for.
|
|
@@ -2113,6 +2369,32 @@ export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select
|
|
|
2113
2369
|
// browser sizes the box by), and the box keeps no bottom padding: exactly `size`
|
|
2114
2370
|
// whole rows, at any width.
|
|
2115
2371
|
const dress = listBox ? "overflow-y-auto [&_option]:py-1" : "appearance-none pe-9";
|
|
2372
|
+
// Locked: focusable, but nothing opens the list or moves the value, and nothing is
|
|
2373
|
+
// reported. The no-op `onChange` keeps a controlled select's value where React put
|
|
2374
|
+
// it (and keeps React from warning about a `value` with no handler).
|
|
2375
|
+
const interaction = locked
|
|
2376
|
+
? {
|
|
2377
|
+
disabled: undefined,
|
|
2378
|
+
"aria-disabled": true as const,
|
|
2379
|
+
onMouseDown: (e: MouseEvent<HTMLSelectElement>) => e.preventDefault(),
|
|
2380
|
+
onKeyDown: (e: KeyboardEvent<HTMLSelectElement>) => {
|
|
2381
|
+
if (!LOCKED_SELECT_KEYS.has(e.key) && !e.ctrlKey && !e.metaKey) e.preventDefault();
|
|
2382
|
+
},
|
|
2383
|
+
onChange: () => {},
|
|
2384
|
+
}
|
|
2385
|
+
: { onMouseDown, onKeyDown, onChange };
|
|
2386
|
+
// While locked the field sits in the reason's Tooltip, which becomes the outermost
|
|
2387
|
+
// box — so `className` (a width, a grid cell) moves onto it, and the form keeps its
|
|
2388
|
+
// layout. `block` so a full-width field stays full width inside it.
|
|
2389
|
+
const boxClass = locked ? undefined : className;
|
|
2390
|
+
const withReason = (box: ReactNode) =>
|
|
2391
|
+
locked ? (
|
|
2392
|
+
<LockedReason lock={lock} className={cn("block", className)}>
|
|
2393
|
+
{box}
|
|
2394
|
+
</LockedReason>
|
|
2395
|
+
) : (
|
|
2396
|
+
box
|
|
2397
|
+
);
|
|
2116
2398
|
if (label === undefined) {
|
|
2117
2399
|
const select = (
|
|
2118
2400
|
<select
|
|
@@ -2126,6 +2408,7 @@ export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select
|
|
|
2126
2408
|
id={id}
|
|
2127
2409
|
size={nativeSize}
|
|
2128
2410
|
{...rest}
|
|
2411
|
+
{...interaction}
|
|
2129
2412
|
// OR-ed with the spread for the reason spelled out on Input's copy: this
|
|
2130
2413
|
// branch wrote `invalid || undefined`, so passing `aria-invalid` by hand
|
|
2131
2414
|
// to a Select — which is what a caller does when the validity is
|
|
@@ -2138,6 +2421,7 @@ export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select
|
|
|
2138
2421
|
dress,
|
|
2139
2422
|
listBox && "py-0",
|
|
2140
2423
|
small && SELECT_SM,
|
|
2424
|
+
locked && FIELD_LOCKED,
|
|
2141
2425
|
selectClassName,
|
|
2142
2426
|
isInvalid && FIELD_INVALID,
|
|
2143
2427
|
)}
|
|
@@ -2153,51 +2437,65 @@ export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select
|
|
|
2153
2437
|
if (hasContent(hint) && !textHint) {
|
|
2154
2438
|
return (
|
|
2155
2439
|
<FieldGroup errorEl={below} reserve={reserve}>
|
|
2156
|
-
|
|
2157
|
-
<div className="
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2440
|
+
{withReason(
|
|
2441
|
+
<div className={cn("flex items-center gap-1.5", boxClass)}>
|
|
2442
|
+
<div className="relative min-w-0 flex-1">
|
|
2443
|
+
{select}
|
|
2444
|
+
{chevron}
|
|
2445
|
+
</div>
|
|
2446
|
+
<span className="flex shrink-0 items-center">{hint}</span>
|
|
2447
|
+
</div>,
|
|
2448
|
+
)}
|
|
2163
2449
|
</FieldGroup>
|
|
2164
2450
|
);
|
|
2165
2451
|
}
|
|
2166
2452
|
return (
|
|
2167
2453
|
<FieldGroup errorEl={below} reserve={reserve}>
|
|
2168
|
-
|
|
2169
|
-
{
|
|
2170
|
-
|
|
2171
|
-
|
|
2454
|
+
{withReason(
|
|
2455
|
+
<div className={cn("relative", boxClass)}>
|
|
2456
|
+
{select}
|
|
2457
|
+
{chevron}
|
|
2458
|
+
</div>,
|
|
2459
|
+
)}
|
|
2172
2460
|
</FieldGroup>
|
|
2173
2461
|
);
|
|
2174
2462
|
}
|
|
2175
2463
|
return (
|
|
2176
2464
|
<FieldGroup errorEl={below} reserve={reserve}>
|
|
2177
|
-
|
|
2178
|
-
<
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
aria-describedby={describedBy}
|
|
2185
|
-
className={cn(
|
|
2186
|
-
FIELD_BASE,
|
|
2187
|
-
// The floated label takes the top strip of a list box too, so its first
|
|
2188
|
-
// row starts under the label rather than behind it. No bottom padding — see
|
|
2189
|
-
// `dress`.
|
|
2190
|
-
listBox ? "pt-5 pb-0" : FIELD_FLOATING_PAD,
|
|
2191
|
-
"peer",
|
|
2192
|
-
dress,
|
|
2193
|
-
selectClassName,
|
|
2194
|
-
isInvalid && FIELD_INVALID,
|
|
2195
|
-
)}
|
|
2465
|
+
{withReason(
|
|
2466
|
+
<FloatingField
|
|
2467
|
+
className={boxClass}
|
|
2468
|
+
htmlFor={fieldId}
|
|
2469
|
+
label={label}
|
|
2470
|
+
staticLabel
|
|
2471
|
+
hint={textHint ? undefined : hint}
|
|
2196
2472
|
>
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2473
|
+
<select
|
|
2474
|
+
ref={ref}
|
|
2475
|
+
id={fieldId}
|
|
2476
|
+
size={nativeSize}
|
|
2477
|
+
{...rest}
|
|
2478
|
+
{...interaction}
|
|
2479
|
+
aria-invalid={isInvalid || rest["aria-invalid"] || undefined}
|
|
2480
|
+
aria-describedby={describedBy}
|
|
2481
|
+
className={cn(
|
|
2482
|
+
FIELD_BASE,
|
|
2483
|
+
// The floated label takes the top strip of a list box too, so its first
|
|
2484
|
+
// row starts under the label rather than behind it. No bottom padding — see
|
|
2485
|
+
// `dress`.
|
|
2486
|
+
listBox ? "pt-5 pb-0" : FIELD_FLOATING_PAD,
|
|
2487
|
+
"peer",
|
|
2488
|
+
dress,
|
|
2489
|
+
locked && FIELD_LOCKED,
|
|
2490
|
+
selectClassName,
|
|
2491
|
+
isInvalid && FIELD_INVALID,
|
|
2492
|
+
)}
|
|
2493
|
+
>
|
|
2494
|
+
{children}
|
|
2495
|
+
</select>
|
|
2496
|
+
{chevron}
|
|
2497
|
+
</FloatingField>,
|
|
2498
|
+
)}
|
|
2201
2499
|
</FieldGroup>
|
|
2202
2500
|
);
|
|
2203
2501
|
});
|
|
@@ -2209,46 +2507,89 @@ export interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElemen
|
|
|
2209
2507
|
invalid?: boolean;
|
|
2210
2508
|
/** See {@link Input}'s `error`. */
|
|
2211
2509
|
error?: ReactNode;
|
|
2510
|
+
/** See {@link Input}'s `hint` (keksdose K4): text is a caption under the field, a
|
|
2511
|
+
* {@link FieldHint} rides the label line (or, unlabelled, the end edge). */
|
|
2512
|
+
hint?: ReactNode;
|
|
2513
|
+
/** See {@link Input}'s `showCount` (keksdose K11): a live "12/80" under the field,
|
|
2514
|
+
* with `maxLength`, announced only near and at the limit. */
|
|
2515
|
+
showCount?: boolean;
|
|
2516
|
+
/** The counter's words. See {@link CharacterCountLabels}. */
|
|
2517
|
+
countLabels?: Partial<CharacterCountLabels>;
|
|
2212
2518
|
}
|
|
2213
2519
|
|
|
2214
2520
|
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(function Textarea(props, ref) {
|
|
2215
|
-
const { className, label, id, placeholder, invalid, error, ...rest } = props;
|
|
2521
|
+
const { className, label, id, placeholder, invalid, error, hint, showCount, countLabels, ...rest } = props;
|
|
2216
2522
|
// See Input's `reserve`.
|
|
2217
|
-
const reserve = "error" in props;
|
|
2523
|
+
const reserve = "error" in props || "hint" in props || "showCount" in props;
|
|
2218
2524
|
const generated = useId();
|
|
2219
2525
|
const fieldId = id ?? generated;
|
|
2526
|
+
const countId = useId();
|
|
2527
|
+
const hintParts = useFieldHint(hint, rest["aria-describedby"]);
|
|
2528
|
+
const maxLength = rest.maxLength;
|
|
2529
|
+
const counting = Boolean(showCount) && typeof maxLength === "number" && maxLength > 0;
|
|
2530
|
+
const length = useTypedLength(rest.value, rest.defaultValue);
|
|
2531
|
+
const countText = useCharacterCountLabels(countLabels);
|
|
2220
2532
|
const { isInvalid, describedBy, errorEl } = useFieldError(
|
|
2221
2533
|
error,
|
|
2222
2534
|
invalid,
|
|
2223
|
-
|
|
2535
|
+
mergeDescribedBy(hintParts.describedBy, counting && countId),
|
|
2224
2536
|
rest["aria-invalid"],
|
|
2225
2537
|
);
|
|
2538
|
+
const counter = counting ? (
|
|
2539
|
+
<CharacterCount
|
|
2540
|
+
id={countId}
|
|
2541
|
+
used={length.used}
|
|
2542
|
+
max={maxLength}
|
|
2543
|
+
labels={countText}
|
|
2544
|
+
caption={<FieldCaption parts={hintParts} className="mt-0" />}
|
|
2545
|
+
/>
|
|
2546
|
+
) : null;
|
|
2547
|
+
const below = fieldBelow(hintParts, counter, errorEl);
|
|
2548
|
+
const onChange = counting
|
|
2549
|
+
? (e: ChangeEvent<HTMLTextAreaElement>) => {
|
|
2550
|
+
length.track(e.target.value);
|
|
2551
|
+
rest.onChange?.(e);
|
|
2552
|
+
}
|
|
2553
|
+
: rest.onChange;
|
|
2226
2554
|
if (label === undefined) {
|
|
2555
|
+
const endHint = hintParts.labelHint;
|
|
2227
2556
|
return (
|
|
2228
|
-
<FieldGroup errorEl={
|
|
2229
|
-
<
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2557
|
+
<FieldGroup errorEl={below} reserve={reserve}>
|
|
2558
|
+
<EndHintRow hint={endHint} className={className}>
|
|
2559
|
+
<textarea
|
|
2560
|
+
ref={ref}
|
|
2561
|
+
id={id}
|
|
2562
|
+
placeholder={placeholder}
|
|
2563
|
+
{...rest}
|
|
2564
|
+
aria-invalid={isInvalid || rest["aria-invalid"] || undefined}
|
|
2565
|
+
aria-describedby={describedBy}
|
|
2566
|
+
onChange={onChange}
|
|
2567
|
+
className={cn(FIELD_BASE, endHint === undefined && className, isInvalid && FIELD_INVALID)}
|
|
2568
|
+
/>
|
|
2569
|
+
</EndHintRow>
|
|
2238
2570
|
</FieldGroup>
|
|
2239
2571
|
);
|
|
2240
2572
|
}
|
|
2573
|
+
const ownPlaceholder = hasPlaceholderText(placeholder);
|
|
2241
2574
|
return (
|
|
2242
|
-
<FieldGroup errorEl={
|
|
2243
|
-
<FloatingField className={className} htmlFor={fieldId} label={label}>
|
|
2575
|
+
<FieldGroup errorEl={below} reserve={reserve}>
|
|
2576
|
+
<FloatingField className={className} htmlFor={fieldId} label={label} hint={hintParts.labelHint}>
|
|
2244
2577
|
<textarea
|
|
2245
2578
|
ref={ref}
|
|
2246
2579
|
id={fieldId}
|
|
2247
|
-
placeholder
|
|
2580
|
+
// The float trick's single space, unless the caller has a placeholder of its
|
|
2581
|
+
// own — then that, shown on focus once the label is out of its way (kastlan
|
|
2582
|
+
// 8, see FLOATING_PLACEHOLDER_ON_FOCUS).
|
|
2583
|
+
placeholder={ownPlaceholder ? placeholder : " "}
|
|
2248
2584
|
{...rest}
|
|
2249
2585
|
aria-invalid={isInvalid || rest["aria-invalid"] || undefined}
|
|
2250
2586
|
aria-describedby={describedBy}
|
|
2251
|
-
|
|
2587
|
+
onChange={onChange}
|
|
2588
|
+
className={cn(
|
|
2589
|
+
FLOATING_INPUT_CLASS,
|
|
2590
|
+
ownPlaceholder && FLOATING_PLACEHOLDER_ON_FOCUS,
|
|
2591
|
+
isInvalid && FIELD_INVALID,
|
|
2592
|
+
)}
|
|
2252
2593
|
/>
|
|
2253
2594
|
</FloatingField>
|
|
2254
2595
|
</FieldGroup>
|