@eifi1/ui-kit 0.20.1 → 0.22.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/dist/chart.d.ts +10 -8
- 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 +27 -8
- package/dist/components/amount-input.js +15 -28
- package/dist/components/amount-input.js.map +1 -1
- package/dist/components/autocomplete.d.ts +8 -0
- package/dist/components/autocomplete.js +9 -4
- package/dist/components/autocomplete.js.map +1 -1
- package/dist/components/button-group.d.ts +10 -8
- package/dist/components/calculator.d.ts +10 -8
- 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/combobox.d.ts +13 -2
- package/dist/components/combobox.js +17 -8
- package/dist/components/combobox.js.map +1 -1
- package/dist/components/confirm-dialog.d.ts +66 -161
- package/dist/components/copy-button.d.ts +10 -8
- package/dist/components/country-select.d.ts +124 -0
- package/dist/components/country-select.js +237 -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 +66 -166
- package/dist/components/danger-confirm.js +133 -49
- 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 +25 -5
- package/dist/components/date-picker.js +110 -59
- package/dist/components/date-picker.js.map +1 -1
- package/dist/components/entity-combobox.d.ts +8 -1
- package/dist/components/entity-combobox.js +9 -4
- package/dist/components/entity-combobox.js.map +1 -1
- package/dist/components/facing-pair.d.ts +10 -8
- package/dist/components/field-anatomy.d.ts +95 -0
- package/dist/components/field-anatomy.js +84 -0
- package/dist/components/field-anatomy.js.map +1 -0
- package/dist/components/field-parts.d.ts +114 -0
- package/dist/components/field-parts.js +72 -0
- package/dist/components/field-parts.js.map +1 -0
- package/dist/components/file-button.d.ts +10 -8
- package/dist/components/file-button.js +13 -3
- package/dist/components/file-button.js.map +1 -1
- package/dist/components/file-dropzone.d.ts +32 -9
- package/dist/components/file-dropzone.js +57 -34
- package/dist/components/file-dropzone.js.map +1 -1
- package/dist/components/form-actions.d.ts +10 -8
- package/dist/components/form-actions.js +61 -3
- package/dist/components/form-actions.js.map +1 -1
- package/dist/components/hover-menu.d.ts +3 -0
- package/dist/components/hover-menu.js +2 -2
- package/dist/components/hover-menu.js.map +1 -1
- package/dist/components/iban-input.d.ts +66 -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 +12 -2
- package/dist/components/icon-picker.js +9 -2
- 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 +127 -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 +20 -8
- package/dist/components/money-field.js.map +1 -1
- package/dist/components/month-picker.d.ts +52 -4
- package/dist/components/month-picker.js +290 -86
- package/dist/components/month-picker.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 +25 -13
- package/dist/components/number-field.js +6 -20
- package/dist/components/number-field.js.map +1 -1
- package/dist/components/number-input.d.ts +70 -13
- package/dist/components/number-input.js +90 -38
- package/dist/components/number-input.js.map +1 -1
- package/dist/components/numpad-sheet.d.ts +10 -8
- 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 +66 -0
- package/dist/components/phone-input.js +171 -0
- package/dist/components/phone-input.js.map +1 -0
- package/dist/components/series-chart.d.ts +10 -8
- package/dist/components/settings-fields.d.ts +10 -8
- package/dist/components/share-card.d.ts +8 -6
- 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 +27 -2
- package/dist/components/swatch-picker.js +73 -31
- 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 +10 -8
- package/dist/components/tile-radio.d.ts +68 -13
- package/dist/components/tile-radio.js +3 -3
- package/dist/components/tile-radio.js.map +1 -1
- package/dist/components/time-input.d.ts +15 -9
- 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/ui.d.ts +10 -8
- package/dist/components/ui.js +280 -78
- 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 +210 -3
- 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 +1 -1
- package/dist/feedback/feedback-thread.d.ts +37 -3
- package/dist/feedback/feedback-thread.js +38 -9
- package/dist/feedback/feedback-thread.js.map +1 -1
- package/dist/{feedback-attachment-WFiXJ8We.d.ts → feedback-attachment-NhUm7Zga.d.ts} +70 -5
- package/dist/feedback.d.ts +2 -2
- package/dist/hooks/use-file-drop.d.ts +10 -8
- package/dist/i18n/defaults.d.ts +10 -8
- package/dist/i18n/defaults.js +17 -2
- package/dist/i18n/defaults.js.map +1 -1
- package/dist/i18n/german.d.ts +10 -8
- package/dist/i18n/german.js +50 -5
- package/dist/i18n/german.js.map +1 -1
- package/dist/i18n/kit-labels.d.ts +10 -8
- package/dist/i18n/kit-labels.js +8 -2
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/languages.d.ts +10 -8
- package/dist/i18n/locales/de-CH.d.ts +10 -8
- package/dist/i18n/locales/en.d.ts +10 -8
- package/dist/i18n/locales/en.js +9 -0
- package/dist/i18n/locales/en.js.map +1 -1
- package/dist/i18n/locales/es.d.ts +10 -8
- package/dist/i18n/locales/es.js +48 -5
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.d.ts +10 -8
- package/dist/i18n/locales/fr.js +48 -5
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.d.ts +10 -8
- package/dist/i18n/locales/hu.js +49 -5
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.d.ts +10 -8
- package/dist/i18n/locales/it.js +49 -5
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.d.ts +10 -8
- package/dist/i18n/locales/zh.js +48 -5
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/i18n/review.d.ts +10 -8
- package/dist/i18n/review.js +8 -1
- package/dist/i18n/review.js.map +1 -1
- package/dist/index.d.ts +17 -10
- package/dist/index.js +57 -2
- package/dist/index.js.map +1 -1
- package/dist/{kit-labels-D1J2_jyD.d.ts → kit-labels-R8mIAc9N.d.ts} +811 -8
- 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/rhf/fields.d.ts +148 -11
- package/dist/rhf/fields.js +332 -3
- package/dist/rhf/fields.js.map +1 -1
- package/dist/rhf/form.d.ts +10 -8
- package/dist/rhf.d.ts +13 -9
- package/dist/rhf.js.map +1 -1
- package/dist/shell/app-shell.d.ts +10 -8
- package/dist/shell/top-bar-brand.d.ts +10 -8
- package/dist/shell/topbar-action-menu.d.ts +13 -1
- package/dist/shell/topbar-action-menu.js +31 -28
- package/dist/shell/topbar-action-menu.js.map +1 -1
- package/dist/shell.d.ts +10 -8
- package/dist/tour/tour.js +20 -24
- package/dist/tour/tour.js.map +1 -1
- package/dist/wizard/stepper-nav.d.ts +10 -8
- package/dist/wizard.d.ts +10 -8
- 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 +49 -56
- package/src/components/autocomplete.tsx +133 -110
- 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/combobox.tsx +292 -253
- package/src/components/country-select.tsx +443 -0
- package/src/components/currency-select.tsx +109 -33
- package/src/components/danger-confirm.tsx +345 -77
- package/src/components/data-table-labels.ts +16 -1
- package/src/components/data-table.tsx +175 -6
- package/src/components/date-picker.tsx +173 -62
- package/src/components/entity-combobox.tsx +107 -87
- package/src/components/field-anatomy.tsx +190 -0
- package/src/components/field-parts.tsx +184 -0
- package/src/components/file-button.tsx +36 -3
- package/src/components/file-dropzone.tsx +106 -38
- package/src/components/form-actions.tsx +115 -2
- package/src/components/hover-menu.tsx +7 -4
- package/src/components/iban-input.tsx +231 -0
- package/src/components/icon-picker.tsx +26 -4
- 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 +10 -0
- package/src/components/month-picker.tsx +399 -63
- 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/sign-chip.tsx +103 -0
- package/src/components/swatch-picker.tsx +118 -30
- package/src/components/switch.tsx +58 -7
- package/src/components/tile-radio.tsx +71 -16
- package/src/components/time-input.tsx +8 -1
- package/src/components/toggle-group.tsx +206 -73
- package/src/components/ui.tsx +472 -105
- package/src/data-table.ts +2 -1
- package/src/feedback/feedback-attachment.tsx +313 -9
- package/src/feedback/feedback-dialog.tsx +2 -0
- package/src/feedback/feedback-thread.tsx +66 -5
- package/src/i18n/defaults.ts +16 -1
- package/src/i18n/german.ts +47 -0
- package/src/i18n/kit-labels.tsx +78 -2
- package/src/i18n/locales/en.ts +10 -0
- package/src/i18n/locales/es.ts +46 -0
- package/src/i18n/locales/fr.ts +45 -0
- package/src/i18n/locales/hu.ts +46 -0
- package/src/i18n/locales/it.ts +47 -0
- package/src/i18n/locales/zh.ts +44 -0
- package/src/i18n/review.ts +7 -0
- package/src/index.ts +53 -3
- 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/rhf/fields.tsx +606 -9
- package/src/rhf.ts +2 -1
- package/src/shell/topbar-action-menu.tsx +53 -29
- package/src/tour/tour.tsx +66 -28
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,15 @@ 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
|
+
type FieldHintParts,
|
|
21
|
+
} from "./field-parts";
|
|
13
22
|
|
|
14
23
|
export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger" | "brand" | "link";
|
|
15
24
|
|
|
@@ -1840,6 +1849,149 @@ export function resolvePasswordRevealLabels(
|
|
|
1840
1849
|
return { ...DEFAULT_PASSWORD_REVEAL_LABELS, ...partial };
|
|
1841
1850
|
}
|
|
1842
1851
|
|
|
1852
|
+
/**
|
|
1853
|
+
* What the character counter of {@link Input} and {@link Textarea} (`showCount`) says.
|
|
1854
|
+
* The visible "12/80" is digits and a slash and is not translated; these are the words
|
|
1855
|
+
* a screen reader gets instead of it.
|
|
1856
|
+
*/
|
|
1857
|
+
export interface CharacterCountLabels {
|
|
1858
|
+
/** The counter as part of the field's description, read when focus reaches the
|
|
1859
|
+
* field: "12 of 80 characters". */
|
|
1860
|
+
count: (used: number, max: number) => string;
|
|
1861
|
+
/** Said once, politely, on entering the last stretch before the limit: "8
|
|
1862
|
+
* characters left". */
|
|
1863
|
+
remaining: (left: number) => string;
|
|
1864
|
+
/** Said once, politely, on reaching the limit. */
|
|
1865
|
+
limitReached: string;
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
export const DEFAULT_CHARACTER_COUNT_LABELS: CharacterCountLabels = {
|
|
1869
|
+
count: (used, max) => `${used} of ${max} characters`,
|
|
1870
|
+
remaining: (left) => (left === 1 ? "1 character left" : `${left} characters left`),
|
|
1871
|
+
limitReached: "Character limit reached",
|
|
1872
|
+
};
|
|
1873
|
+
|
|
1874
|
+
/** The length the browser's own `maxLength` counts — UTF-16 code units — so the counter
|
|
1875
|
+
* and the limit can never disagree about whether a field is full. */
|
|
1876
|
+
function textLength(value: unknown): number {
|
|
1877
|
+
return value === undefined || value === null ? 0 : String(value).length;
|
|
1878
|
+
}
|
|
1879
|
+
|
|
1880
|
+
/** The counter's length: the controlled `value`'s, else what the field has been typed
|
|
1881
|
+
* to (from `defaultValue` on). `track` is called from the field's change handler. */
|
|
1882
|
+
function useTypedLength(value: unknown, defaultValue: unknown) {
|
|
1883
|
+
const [typed, setTyped] = useState(() => textLength(defaultValue));
|
|
1884
|
+
return { used: value !== undefined ? textLength(value) : typed, track: (text: string) => setTyped(text.length) };
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
type CountZone = "ok" | "near" | "limit";
|
|
1888
|
+
|
|
1889
|
+
/** The last stretch before the limit: a tenth of it, at least 1 and at most 20 — "8
|
|
1890
|
+
* left" of 80, and 20 of 2,000 rather than 200, which is not "near" in any sense a
|
|
1891
|
+
* person typing means. */
|
|
1892
|
+
function nearLimitAt(max: number): number {
|
|
1893
|
+
return Math.min(20, Math.max(1, Math.ceil(max * 0.1)));
|
|
1894
|
+
}
|
|
1895
|
+
|
|
1896
|
+
function countZone(used: number, max: number): CountZone {
|
|
1897
|
+
if (used >= max) return "limit";
|
|
1898
|
+
return max - used <= nearLimitAt(max) ? "near" : "ok";
|
|
1899
|
+
}
|
|
1900
|
+
|
|
1901
|
+
/**
|
|
1902
|
+
* The live "12/80" under a field with `showCount` (keksdose K11: the admin broadcast,
|
|
1903
|
+
* support replies, canned replies and the handoff note each counted by hand, and one
|
|
1904
|
+
* announced every keystroke).
|
|
1905
|
+
*
|
|
1906
|
+
* Three channels, each saying only what it should:
|
|
1907
|
+
* - the digits, `aria-hidden` — "12 slash 80" is not a sentence;
|
|
1908
|
+
* - a `hidden` "12 of 80 characters" on the field's `aria-describedby`, so the count is
|
|
1909
|
+
* read with the field when focus reaches it, and never while typing;
|
|
1910
|
+
* - a polite live region that speaks ONCE per stretch: on entering the last tenth
|
|
1911
|
+
* ("8 characters left") and on reaching the limit. A region that changed on every
|
|
1912
|
+
* keystroke would talk over the user's own typing echo for the whole message.
|
|
1913
|
+
* Nothing is said on mount: a stored note already near its limit is not news.
|
|
1914
|
+
*/
|
|
1915
|
+
function CharacterCount({
|
|
1916
|
+
id,
|
|
1917
|
+
used,
|
|
1918
|
+
max,
|
|
1919
|
+
labels,
|
|
1920
|
+
caption,
|
|
1921
|
+
}: {
|
|
1922
|
+
id: string;
|
|
1923
|
+
used: number;
|
|
1924
|
+
max: number;
|
|
1925
|
+
labels: CharacterCountLabels;
|
|
1926
|
+
caption: ReactNode;
|
|
1927
|
+
}) {
|
|
1928
|
+
const zone = countZone(used, max);
|
|
1929
|
+
// What the live region holds, re-set only when the stretch changes — the previous
|
|
1930
|
+
// render's zone kept in state, the pattern React documents for "adjusting state when
|
|
1931
|
+
// a prop changes", so the region's text (and so the announcement) changes at most
|
|
1932
|
+
// once per crossing.
|
|
1933
|
+
const [said, setSaid] = useState<{ zone: CountZone; text: string }>({ zone, text: "" });
|
|
1934
|
+
if (said.zone !== zone) {
|
|
1935
|
+
setSaid({
|
|
1936
|
+
zone,
|
|
1937
|
+
text: zone === "near" ? labels.remaining(max - used) : zone === "limit" ? labels.limitReached : "",
|
|
1938
|
+
});
|
|
1939
|
+
}
|
|
1940
|
+
return (
|
|
1941
|
+
// `relative` holds the sr-only region (see sr-only-containment.test).
|
|
1942
|
+
<div className="relative mt-1 flex items-start gap-2">
|
|
1943
|
+
<div className="min-w-0 flex-1">{caption}</div>
|
|
1944
|
+
<span
|
|
1945
|
+
aria-hidden
|
|
1946
|
+
className={cn(
|
|
1947
|
+
"shrink-0 text-[11px] leading-tight tabular-nums",
|
|
1948
|
+
zone === "limit"
|
|
1949
|
+
? "text-[var(--danger)]"
|
|
1950
|
+
: zone === "near"
|
|
1951
|
+
? "text-[var(--warning)]"
|
|
1952
|
+
: "text-[var(--text-muted)]",
|
|
1953
|
+
)}
|
|
1954
|
+
>
|
|
1955
|
+
{used}/{max}
|
|
1956
|
+
</span>
|
|
1957
|
+
<span id={id} hidden>
|
|
1958
|
+
{labels.count(used, max)}
|
|
1959
|
+
</span>
|
|
1960
|
+
<span role="status" className="sr-only">
|
|
1961
|
+
{said.text}
|
|
1962
|
+
</span>
|
|
1963
|
+
</div>
|
|
1964
|
+
);
|
|
1965
|
+
}
|
|
1966
|
+
|
|
1967
|
+
/** What goes under a text field: the caption (beside the counter, when there is one),
|
|
1968
|
+
* then the error. `null` when there is nothing, so a field with none keeps exactly the
|
|
1969
|
+
* DOM it had (see {@link FieldGroup}). */
|
|
1970
|
+
function fieldBelow(hint: FieldHintParts, counter: ReactNode, errorEl: ReactNode): ReactNode {
|
|
1971
|
+
if (counter === null && hint.captionText === undefined && errorEl === null) return null;
|
|
1972
|
+
return (
|
|
1973
|
+
<>
|
|
1974
|
+
{counter ?? <FieldCaption parts={hint} />}
|
|
1975
|
+
{errorEl}
|
|
1976
|
+
</>
|
|
1977
|
+
);
|
|
1978
|
+
}
|
|
1979
|
+
|
|
1980
|
+
/**
|
|
1981
|
+
* kastlan 8: a caller's placeholder on a LABELLED field, shown once the label has
|
|
1982
|
+
* floated out of its way — on focus — and transparent until then, while the label sits
|
|
1983
|
+
* where the placeholder would be. Without it the field forced `placeholder=" "` for the
|
|
1984
|
+
* float trick and dropped the caller's text without a word (kastlan's "note" field lost
|
|
1985
|
+
* its hint). `:placeholder-shown` still drives the float: it matches an empty field
|
|
1986
|
+
* whatever the placeholder's text is.
|
|
1987
|
+
*/
|
|
1988
|
+
const FLOATING_PLACEHOLDER_ON_FOCUS = "focus:placeholder:text-[var(--text-placeholder)]";
|
|
1989
|
+
|
|
1990
|
+
/** A placeholder with something to show. `""` and blanks are the float trick's own
|
|
1991
|
+
* `" "`, never the caller's hint. */
|
|
1992
|
+
const hasPlaceholderText = (placeholder: string | undefined): placeholder is string =>
|
|
1993
|
+
placeholder !== undefined && placeholder.trim() !== "";
|
|
1994
|
+
|
|
1843
1995
|
// Native date/time inputs only reveal the calendar via the tiny trailing icon;
|
|
1844
1996
|
// open the picker on a click anywhere in the field instead (feedback #224).
|
|
1845
1997
|
const PICKER_TYPES = new Set(["date", "datetime-local", "month", "time", "week"]);
|
|
@@ -1874,12 +2026,42 @@ export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
|
|
1874
2026
|
* `invalid`, so the field paints as well as announces. `invalid` alone still
|
|
1875
2027
|
* covers the case where the message lives elsewhere. See {@link useFieldError}. */
|
|
1876
2028
|
error?: ReactNode;
|
|
2029
|
+
/**
|
|
2030
|
+
* Standing advice for the field (keksdose K4: Input had `error` but no `hint`, so the
|
|
2031
|
+
* app built the caption by hand under each field, and half of those never reached a
|
|
2032
|
+
* screen reader). The {@link Select} rule, so every field reads alike:
|
|
2033
|
+
* - plain TEXT (a string or a number) is a caption UNDER the field, attached with
|
|
2034
|
+
* `aria-describedby` — after the caller's own ids, before the error's;
|
|
2035
|
+
* - anything else (a {@link FieldHint} "?") rides the label line beside the
|
|
2036
|
+
* animated label, as NumberInput's does — or, on a field with no label, sits at the
|
|
2037
|
+
* field's end edge outside the box, and `className` then styles that row.
|
|
2038
|
+
*/
|
|
2039
|
+
hint?: ReactNode;
|
|
2040
|
+
/**
|
|
2041
|
+
* A live "12/80" under the field, with `maxLength` (keksdose K11 — the broadcast,
|
|
2042
|
+
* support and handoff-note fields counted by hand). Read with the field as "12 of 80
|
|
2043
|
+
* characters", and announced politely only on entering the last stretch and on
|
|
2044
|
+
* reaching the limit — never on every keystroke. See {@link CharacterCount}. Ignored
|
|
2045
|
+
* without a `maxLength`: a count with no limit is not what this answers. Counts
|
|
2046
|
+
* UTF-16 code units, as `maxLength` itself does. Controlled, it counts `value`;
|
|
2047
|
+
* uncontrolled, `defaultValue` and then every change event — a value written from
|
|
2048
|
+
* outside without one (a form library's `reset`) shows from the next keystroke.
|
|
2049
|
+
*/
|
|
2050
|
+
showCount?: boolean;
|
|
2051
|
+
/** The counter's words. See {@link CharacterCountLabels}. */
|
|
2052
|
+
countLabels?: Partial<CharacterCountLabels>;
|
|
1877
2053
|
/** Names for the password reveal toggle, English by default — it is the one
|
|
1878
2054
|
* string this component renders on its own behalf, and a German form was
|
|
1879
2055
|
* reading it out in English. See {@link PasswordRevealLabels}. */
|
|
1880
2056
|
passwordLabels?: Partial<PasswordRevealLabels>;
|
|
1881
2057
|
}
|
|
1882
2058
|
|
|
2059
|
+
/** The counter's words: the `characterCount` namespace — prop > `<UiKitProvider
|
|
2060
|
+
* labels>` > English. One place, so the namespace is named once. */
|
|
2061
|
+
function useCharacterCountLabels(prop: Partial<CharacterCountLabels> | undefined): CharacterCountLabels {
|
|
2062
|
+
return useKitLabels("characterCount", DEFAULT_CHARACTER_COUNT_LABELS, prop);
|
|
2063
|
+
}
|
|
2064
|
+
|
|
1883
2065
|
export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(props, ref) {
|
|
1884
2066
|
const {
|
|
1885
2067
|
className,
|
|
@@ -1891,19 +2073,47 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(pro
|
|
|
1891
2073
|
variant = "field",
|
|
1892
2074
|
invalid,
|
|
1893
2075
|
error,
|
|
2076
|
+
hint,
|
|
2077
|
+
showCount,
|
|
2078
|
+
countLabels,
|
|
1894
2079
|
passwordLabels,
|
|
1895
2080
|
...rest
|
|
1896
2081
|
} = props;
|
|
1897
|
-
// Passed at all — even as `undefined` — the field keeps its box; see FieldGroup.
|
|
1898
|
-
|
|
2082
|
+
// Passed at all — even as `undefined` — the field keeps its box; see FieldGroup. The
|
|
2083
|
+
// same for `hint` and `showCount` (keksdose, 0.22): a caption coming and going under
|
|
2084
|
+
// the control must not rebuild it, or focus and the caret are lost mid-edit.
|
|
2085
|
+
const reserve = "error" in props || "hint" in props || "showCount" in props;
|
|
1899
2086
|
const generated = useId();
|
|
1900
2087
|
const fieldId = id ?? generated;
|
|
2088
|
+
const countId = useId();
|
|
2089
|
+
const hintParts = useFieldHint(hint, rest["aria-describedby"]);
|
|
2090
|
+
const maxLength = rest.maxLength;
|
|
2091
|
+
const counting = Boolean(showCount) && typeof maxLength === "number" && maxLength > 0;
|
|
2092
|
+
const length = useTypedLength(rest.value, rest.defaultValue);
|
|
2093
|
+
const countText = useCharacterCountLabels(countLabels);
|
|
1901
2094
|
const { isInvalid, describedBy, errorEl } = useFieldError(
|
|
1902
2095
|
error,
|
|
1903
2096
|
invalid,
|
|
1904
|
-
|
|
2097
|
+
// Caller's, caption, count — then the error, appended by the hook.
|
|
2098
|
+
mergeDescribedBy(hintParts.describedBy, counting && countId),
|
|
1905
2099
|
rest["aria-invalid"],
|
|
1906
2100
|
);
|
|
2101
|
+
const counter = counting ? (
|
|
2102
|
+
<CharacterCount
|
|
2103
|
+
id={countId}
|
|
2104
|
+
used={length.used}
|
|
2105
|
+
max={maxLength}
|
|
2106
|
+
labels={countText}
|
|
2107
|
+
caption={<FieldCaption parts={hintParts} className="mt-0" />}
|
|
2108
|
+
/>
|
|
2109
|
+
) : null;
|
|
2110
|
+
const below = fieldBelow(hintParts, counter, errorEl);
|
|
2111
|
+
const onChange = counting
|
|
2112
|
+
? (e: ChangeEvent<HTMLInputElement>) => {
|
|
2113
|
+
length.track(e.target.value);
|
|
2114
|
+
rest.onChange?.(e);
|
|
2115
|
+
}
|
|
2116
|
+
: rest.onChange;
|
|
1907
2117
|
const asDisplay = useMediaQuery(PHONE_QUERY, false) && variant === "display";
|
|
1908
2118
|
// Password fields get a reveal toggle so users can check what they typed.
|
|
1909
2119
|
const isPassword = type === "password";
|
|
@@ -1943,66 +2153,92 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(pro
|
|
|
1943
2153
|
</button>
|
|
1944
2154
|
) : null;
|
|
1945
2155
|
if (label === undefined) {
|
|
2156
|
+
// A "?" with no label line to ride goes at the end edge, outside the box, and
|
|
2157
|
+
// `className` moves to that row (see `hint`); without one nothing moves.
|
|
2158
|
+
const endHint = hintParts.labelHint;
|
|
2159
|
+
const ownClass = endHint === undefined ? className : undefined;
|
|
1946
2160
|
if (!isPassword) {
|
|
1947
2161
|
return (
|
|
1948
|
-
<FieldGroup errorEl={
|
|
1949
|
-
<
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
2162
|
+
<FieldGroup errorEl={below} reserve={reserve}>
|
|
2163
|
+
<EndHintRow hint={endHint} className={className}>
|
|
2164
|
+
<input
|
|
2165
|
+
ref={ref}
|
|
2166
|
+
id={id}
|
|
2167
|
+
type={type}
|
|
2168
|
+
placeholder={placeholder}
|
|
2169
|
+
{...rest}
|
|
2170
|
+
// AFTER the spread, so the prop wins — but OR-ed with whatever the spread
|
|
2171
|
+
// carried, or setting `invalid` would have quietly deleted a caller's own
|
|
2172
|
+
// `aria-invalid`. The prop is the one that also paints; a bare attribute
|
|
2173
|
+
// still announces, which is all it ever did.
|
|
2174
|
+
aria-invalid={isInvalid || rest["aria-invalid"] || undefined}
|
|
2175
|
+
// Likewise merged rather than replaced — see {@link useFieldError}.
|
|
2176
|
+
aria-describedby={describedBy}
|
|
2177
|
+
onClick={handleClick}
|
|
2178
|
+
onChange={onChange}
|
|
2179
|
+
className={cn(FIELD_BASE, ownClass, inputClassName, isInvalid && FIELD_INVALID)}
|
|
2180
|
+
/>
|
|
2181
|
+
</EndHintRow>
|
|
1965
2182
|
</FieldGroup>
|
|
1966
2183
|
);
|
|
1967
2184
|
}
|
|
1968
2185
|
return (
|
|
1969
|
-
<FieldGroup errorEl={
|
|
1970
|
-
<
|
|
1971
|
-
<
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
2186
|
+
<FieldGroup errorEl={below} reserve={reserve}>
|
|
2187
|
+
<EndHintRow hint={endHint} className={className}>
|
|
2188
|
+
<div className={cn("relative", ownClass)}>
|
|
2189
|
+
<input
|
|
2190
|
+
ref={ref}
|
|
2191
|
+
id={id}
|
|
2192
|
+
type={effectiveType}
|
|
2193
|
+
placeholder={placeholder}
|
|
2194
|
+
{...rest}
|
|
2195
|
+
aria-invalid={isInvalid || rest["aria-invalid"] || undefined}
|
|
2196
|
+
aria-describedby={describedBy}
|
|
2197
|
+
onChange={onChange}
|
|
2198
|
+
className={cn(FIELD_BASE, "pe-9", inputClassName, isInvalid && FIELD_INVALID)}
|
|
2199
|
+
/>
|
|
2200
|
+
{revealToggle}
|
|
2201
|
+
</div>
|
|
2202
|
+
</EndHintRow>
|
|
1983
2203
|
</FieldGroup>
|
|
1984
2204
|
);
|
|
1985
2205
|
}
|
|
2206
|
+
const ownPlaceholder = hasPlaceholderText(placeholder);
|
|
1986
2207
|
return (
|
|
1987
|
-
<FieldGroup errorEl={
|
|
1988
|
-
<FloatingField
|
|
2208
|
+
<FieldGroup errorEl={below} reserve={reserve}>
|
|
2209
|
+
<FloatingField
|
|
2210
|
+
className={className}
|
|
2211
|
+
htmlFor={fieldId}
|
|
2212
|
+
label={label}
|
|
2213
|
+
srOnlyLabel={asDisplay}
|
|
2214
|
+
hint={hintParts.labelHint}
|
|
2215
|
+
>
|
|
1989
2216
|
<input
|
|
1990
2217
|
ref={ref}
|
|
1991
2218
|
id={fieldId}
|
|
1992
2219
|
type={effectiveType}
|
|
1993
|
-
// A labelled field
|
|
1994
|
-
// floating label's peer-placeholder-shown trick.
|
|
1995
|
-
//
|
|
1996
|
-
//
|
|
1997
|
-
|
|
2220
|
+
// A labelled field with no placeholder of its own gets a single space, which
|
|
2221
|
+
// feeds the floating label's peer-placeholder-shown trick. A caller's own is
|
|
2222
|
+
// kept and shown on focus, once the label has floated out of its way (kastlan
|
|
2223
|
+
// 8) — see FLOATING_PLACEHOLDER_ON_FOCUS. With the label sr-only there is no
|
|
2224
|
+
// float left to drive, and an empty borderless line would say nothing at all
|
|
2225
|
+
// — so there the label text is the placeholder when the caller gave none.
|
|
2226
|
+
placeholder={
|
|
2227
|
+
asDisplay
|
|
2228
|
+
? (placeholder ?? (typeof label === "string" ? label : " "))
|
|
2229
|
+
: ownPlaceholder
|
|
2230
|
+
? placeholder
|
|
2231
|
+
: " "
|
|
2232
|
+
}
|
|
1998
2233
|
{...rest}
|
|
1999
2234
|
aria-invalid={isInvalid || rest["aria-invalid"] || undefined}
|
|
2000
2235
|
aria-describedby={describedBy}
|
|
2001
2236
|
onClick={handleClick}
|
|
2237
|
+
onChange={onChange}
|
|
2002
2238
|
className={cn(
|
|
2003
2239
|
asDisplay
|
|
2004
2240
|
? cn(FIELD_DISPLAY, "text-xl font-semibold leading-snug")
|
|
2005
|
-
: FLOATING_INPUT_CLASS,
|
|
2241
|
+
: cn(FLOATING_INPUT_CLASS, ownPlaceholder && FLOATING_PLACEHOLDER_ON_FOCUS),
|
|
2006
2242
|
isPassword && "pe-9",
|
|
2007
2243
|
inputClassName,
|
|
2008
2244
|
isInvalid && FIELD_INVALID,
|
|
@@ -2045,6 +2281,29 @@ export interface SelectProps extends Omit<SelectHTMLAttributes<HTMLSelectElement
|
|
|
2045
2281
|
* the chevron is positioned against — so it could set a width and nothing
|
|
2046
2282
|
* else; this is the way to the element. See {@link Input}'s `inputClassName`. */
|
|
2047
2283
|
selectClassName?: string;
|
|
2284
|
+
/**
|
|
2285
|
+
* Why the choice cannot be changed — {@link Button}'s `disabledReason`, for a select
|
|
2286
|
+
* that SAVES on change (a role picker in a members table, a status in a row). keksdose
|
|
2287
|
+
* K3: those sat inside a hand-rolled `SaveGuard` that forced a native `disabled`, which
|
|
2288
|
+
* took the field out of the tab order so the reason never reached a keyboard.
|
|
2289
|
+
*
|
|
2290
|
+
* With a reason the select is `aria-disabled` instead — still focusable, still showing
|
|
2291
|
+
* its value — its list does not open (pointer and keys are swallowed, Tab and Escape
|
|
2292
|
+
* excepted), `onChange` is never called, and the reason is in the kit {@link Tooltip}
|
|
2293
|
+
* and on `aria-describedby`. It wears the settled look of a disabled field and drops
|
|
2294
|
+
* its chevron, as a disabled Select does (dev#474). It wins over `disabled`.
|
|
2295
|
+
*
|
|
2296
|
+
* Use it CONTROLLED (`value` + `onChange`): React puts a controlled value back after
|
|
2297
|
+
* the swallowed change. A touch platform's own picker may still open; nothing it picks
|
|
2298
|
+
* is kept or reported.
|
|
2299
|
+
*/
|
|
2300
|
+
disabledReason?: ReactNode;
|
|
2301
|
+
/**
|
|
2302
|
+
* This select COMMITS — choosing saves. Under a locked {@link WriteLockProvider} it is
|
|
2303
|
+
* locked the `disabledReason` way with the lock's reason (which wins over its own).
|
|
2304
|
+
* No provider, or an unlocked one: no effect. Button's `commit`, for keksdose K3.
|
|
2305
|
+
*/
|
|
2306
|
+
commit?: boolean;
|
|
2048
2307
|
}
|
|
2049
2308
|
|
|
2050
2309
|
// The compact select: the field's colours, a toolbar button's height. `py-0` and a
|
|
@@ -2052,12 +2311,37 @@ export interface SelectProps extends Omit<SelectHTMLAttributes<HTMLSelectElement
|
|
|
2052
2311
|
// height the caller's type brings with it.
|
|
2053
2312
|
const SELECT_SM = "h-7 py-0 ps-2 pe-7 text-xs";
|
|
2054
2313
|
|
|
2314
|
+
/** Keys a locked select still answers: leaving it, and dismissing its tooltip. Every
|
|
2315
|
+
* other key would open the list or step the value. */
|
|
2316
|
+
const LOCKED_SELECT_KEYS = new Set(["Tab", "Escape", "Shift"]);
|
|
2317
|
+
|
|
2318
|
+
/** FIELD_BASE's `disabled:` look, for a select that is locked without being `disabled`
|
|
2319
|
+
* (the `:disabled` variants cannot match it). */
|
|
2320
|
+
const FIELD_LOCKED = "cursor-not-allowed bg-[var(--bg-surface-2)] text-[var(--text-muted)]";
|
|
2321
|
+
|
|
2055
2322
|
export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select(props, ref) {
|
|
2056
|
-
const {
|
|
2057
|
-
|
|
2323
|
+
const {
|
|
2324
|
+
className,
|
|
2325
|
+
label,
|
|
2326
|
+
id,
|
|
2327
|
+
children,
|
|
2328
|
+
invalid,
|
|
2329
|
+
error,
|
|
2330
|
+
hint,
|
|
2331
|
+
size,
|
|
2332
|
+
selectClassName,
|
|
2333
|
+
disabledReason,
|
|
2334
|
+
commit,
|
|
2335
|
+
onMouseDown,
|
|
2336
|
+
onKeyDown,
|
|
2337
|
+
onChange,
|
|
2338
|
+
...rest
|
|
2339
|
+
} = props;
|
|
2058
2340
|
// See Input's `reserve`.
|
|
2059
|
-
const reserve = "error" in props;
|
|
2341
|
+
const reserve = "error" in props || "hint" in props || "showCount" in props;
|
|
2060
2342
|
const generated = useId();
|
|
2343
|
+
const lock = useLockReason(commit, disabledReason);
|
|
2344
|
+
const locked = lock.locked;
|
|
2061
2345
|
// Only a number reaches the DOM; the two words are this component's own.
|
|
2062
2346
|
const nativeSize = typeof size === "number" ? size : undefined;
|
|
2063
2347
|
// A numeric size above 1 (or `multiple`) makes the browser draw a LIST BOX — all
|
|
@@ -2076,12 +2360,9 @@ export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select
|
|
|
2076
2360
|
error,
|
|
2077
2361
|
invalid,
|
|
2078
2362
|
// 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"],
|
|
2363
|
+
// second, the order useFieldError keeps for a caller's own description. A lock's
|
|
2364
|
+
// reason sits between the two.
|
|
2365
|
+
mergeDescribedBy(rest["aria-describedby"], textHint && hintId, locked && lock.reasonId),
|
|
2085
2366
|
rest["aria-invalid"],
|
|
2086
2367
|
);
|
|
2087
2368
|
const below =
|
|
@@ -2099,9 +2380,10 @@ export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select
|
|
|
2099
2380
|
// in from the end border and matches both themes — feedback #223. A DISABLED
|
|
2100
2381
|
// select has no menu to drop, so it drops the chevron too: the arrow is the one
|
|
2101
2382
|
// 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).
|
|
2383
|
+
// account type became read-only and still looked exactly like a picker). A locked
|
|
2384
|
+
// one has no menu either.
|
|
2103
2385
|
const chevron =
|
|
2104
|
-
rest.disabled || listBox ? null : (
|
|
2386
|
+
rest.disabled || listBox || locked ? null : (
|
|
2105
2387
|
<FieldChevron className={small ? "end-1.5 size-3.5" : undefined} />
|
|
2106
2388
|
);
|
|
2107
2389
|
// `pe-9` is the room the chevron takes; a list box has none to make room for.
|
|
@@ -2113,6 +2395,32 @@ export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select
|
|
|
2113
2395
|
// browser sizes the box by), and the box keeps no bottom padding: exactly `size`
|
|
2114
2396
|
// whole rows, at any width.
|
|
2115
2397
|
const dress = listBox ? "overflow-y-auto [&_option]:py-1" : "appearance-none pe-9";
|
|
2398
|
+
// Locked: focusable, but nothing opens the list or moves the value, and nothing is
|
|
2399
|
+
// reported. The no-op `onChange` keeps a controlled select's value where React put
|
|
2400
|
+
// it (and keeps React from warning about a `value` with no handler).
|
|
2401
|
+
const interaction = locked
|
|
2402
|
+
? {
|
|
2403
|
+
disabled: undefined,
|
|
2404
|
+
"aria-disabled": true as const,
|
|
2405
|
+
onMouseDown: (e: MouseEvent<HTMLSelectElement>) => e.preventDefault(),
|
|
2406
|
+
onKeyDown: (e: KeyboardEvent<HTMLSelectElement>) => {
|
|
2407
|
+
if (!LOCKED_SELECT_KEYS.has(e.key) && !e.ctrlKey && !e.metaKey) e.preventDefault();
|
|
2408
|
+
},
|
|
2409
|
+
onChange: () => {},
|
|
2410
|
+
}
|
|
2411
|
+
: { onMouseDown, onKeyDown, onChange };
|
|
2412
|
+
// While locked the field sits in the reason's Tooltip, which becomes the outermost
|
|
2413
|
+
// box — so `className` (a width, a grid cell) moves onto it, and the form keeps its
|
|
2414
|
+
// layout. `block` so a full-width field stays full width inside it.
|
|
2415
|
+
const boxClass = locked ? undefined : className;
|
|
2416
|
+
const withReason = (box: ReactNode) =>
|
|
2417
|
+
locked ? (
|
|
2418
|
+
<LockedReason lock={lock} className={cn("block", className)}>
|
|
2419
|
+
{box}
|
|
2420
|
+
</LockedReason>
|
|
2421
|
+
) : (
|
|
2422
|
+
box
|
|
2423
|
+
);
|
|
2116
2424
|
if (label === undefined) {
|
|
2117
2425
|
const select = (
|
|
2118
2426
|
<select
|
|
@@ -2126,6 +2434,7 @@ export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select
|
|
|
2126
2434
|
id={id}
|
|
2127
2435
|
size={nativeSize}
|
|
2128
2436
|
{...rest}
|
|
2437
|
+
{...interaction}
|
|
2129
2438
|
// OR-ed with the spread for the reason spelled out on Input's copy: this
|
|
2130
2439
|
// branch wrote `invalid || undefined`, so passing `aria-invalid` by hand
|
|
2131
2440
|
// to a Select — which is what a caller does when the validity is
|
|
@@ -2138,6 +2447,7 @@ export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select
|
|
|
2138
2447
|
dress,
|
|
2139
2448
|
listBox && "py-0",
|
|
2140
2449
|
small && SELECT_SM,
|
|
2450
|
+
locked && FIELD_LOCKED,
|
|
2141
2451
|
selectClassName,
|
|
2142
2452
|
isInvalid && FIELD_INVALID,
|
|
2143
2453
|
)}
|
|
@@ -2153,51 +2463,65 @@ export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select
|
|
|
2153
2463
|
if (hasContent(hint) && !textHint) {
|
|
2154
2464
|
return (
|
|
2155
2465
|
<FieldGroup errorEl={below} reserve={reserve}>
|
|
2156
|
-
|
|
2157
|
-
<div className="
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2466
|
+
{withReason(
|
|
2467
|
+
<div className={cn("flex items-center gap-1.5", boxClass)}>
|
|
2468
|
+
<div className="relative min-w-0 flex-1">
|
|
2469
|
+
{select}
|
|
2470
|
+
{chevron}
|
|
2471
|
+
</div>
|
|
2472
|
+
<span className="flex shrink-0 items-center">{hint}</span>
|
|
2473
|
+
</div>,
|
|
2474
|
+
)}
|
|
2163
2475
|
</FieldGroup>
|
|
2164
2476
|
);
|
|
2165
2477
|
}
|
|
2166
2478
|
return (
|
|
2167
2479
|
<FieldGroup errorEl={below} reserve={reserve}>
|
|
2168
|
-
|
|
2169
|
-
{
|
|
2170
|
-
|
|
2171
|
-
|
|
2480
|
+
{withReason(
|
|
2481
|
+
<div className={cn("relative", boxClass)}>
|
|
2482
|
+
{select}
|
|
2483
|
+
{chevron}
|
|
2484
|
+
</div>,
|
|
2485
|
+
)}
|
|
2172
2486
|
</FieldGroup>
|
|
2173
2487
|
);
|
|
2174
2488
|
}
|
|
2175
2489
|
return (
|
|
2176
2490
|
<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
|
-
)}
|
|
2491
|
+
{withReason(
|
|
2492
|
+
<FloatingField
|
|
2493
|
+
className={boxClass}
|
|
2494
|
+
htmlFor={fieldId}
|
|
2495
|
+
label={label}
|
|
2496
|
+
staticLabel
|
|
2497
|
+
hint={textHint ? undefined : hint}
|
|
2196
2498
|
>
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2499
|
+
<select
|
|
2500
|
+
ref={ref}
|
|
2501
|
+
id={fieldId}
|
|
2502
|
+
size={nativeSize}
|
|
2503
|
+
{...rest}
|
|
2504
|
+
{...interaction}
|
|
2505
|
+
aria-invalid={isInvalid || rest["aria-invalid"] || undefined}
|
|
2506
|
+
aria-describedby={describedBy}
|
|
2507
|
+
className={cn(
|
|
2508
|
+
FIELD_BASE,
|
|
2509
|
+
// The floated label takes the top strip of a list box too, so its first
|
|
2510
|
+
// row starts under the label rather than behind it. No bottom padding — see
|
|
2511
|
+
// `dress`.
|
|
2512
|
+
listBox ? "pt-5 pb-0" : FIELD_FLOATING_PAD,
|
|
2513
|
+
"peer",
|
|
2514
|
+
dress,
|
|
2515
|
+
locked && FIELD_LOCKED,
|
|
2516
|
+
selectClassName,
|
|
2517
|
+
isInvalid && FIELD_INVALID,
|
|
2518
|
+
)}
|
|
2519
|
+
>
|
|
2520
|
+
{children}
|
|
2521
|
+
</select>
|
|
2522
|
+
{chevron}
|
|
2523
|
+
</FloatingField>,
|
|
2524
|
+
)}
|
|
2201
2525
|
</FieldGroup>
|
|
2202
2526
|
);
|
|
2203
2527
|
});
|
|
@@ -2209,46 +2533,89 @@ export interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElemen
|
|
|
2209
2533
|
invalid?: boolean;
|
|
2210
2534
|
/** See {@link Input}'s `error`. */
|
|
2211
2535
|
error?: ReactNode;
|
|
2536
|
+
/** See {@link Input}'s `hint` (keksdose K4): text is a caption under the field, a
|
|
2537
|
+
* {@link FieldHint} rides the label line (or, unlabelled, the end edge). */
|
|
2538
|
+
hint?: ReactNode;
|
|
2539
|
+
/** See {@link Input}'s `showCount` (keksdose K11): a live "12/80" under the field,
|
|
2540
|
+
* with `maxLength`, announced only near and at the limit. */
|
|
2541
|
+
showCount?: boolean;
|
|
2542
|
+
/** The counter's words. See {@link CharacterCountLabels}. */
|
|
2543
|
+
countLabels?: Partial<CharacterCountLabels>;
|
|
2212
2544
|
}
|
|
2213
2545
|
|
|
2214
2546
|
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(function Textarea(props, ref) {
|
|
2215
|
-
const { className, label, id, placeholder, invalid, error, ...rest } = props;
|
|
2547
|
+
const { className, label, id, placeholder, invalid, error, hint, showCount, countLabels, ...rest } = props;
|
|
2216
2548
|
// See Input's `reserve`.
|
|
2217
|
-
const reserve = "error" in props;
|
|
2549
|
+
const reserve = "error" in props || "hint" in props || "showCount" in props;
|
|
2218
2550
|
const generated = useId();
|
|
2219
2551
|
const fieldId = id ?? generated;
|
|
2552
|
+
const countId = useId();
|
|
2553
|
+
const hintParts = useFieldHint(hint, rest["aria-describedby"]);
|
|
2554
|
+
const maxLength = rest.maxLength;
|
|
2555
|
+
const counting = Boolean(showCount) && typeof maxLength === "number" && maxLength > 0;
|
|
2556
|
+
const length = useTypedLength(rest.value, rest.defaultValue);
|
|
2557
|
+
const countText = useCharacterCountLabels(countLabels);
|
|
2220
2558
|
const { isInvalid, describedBy, errorEl } = useFieldError(
|
|
2221
2559
|
error,
|
|
2222
2560
|
invalid,
|
|
2223
|
-
|
|
2561
|
+
mergeDescribedBy(hintParts.describedBy, counting && countId),
|
|
2224
2562
|
rest["aria-invalid"],
|
|
2225
2563
|
);
|
|
2564
|
+
const counter = counting ? (
|
|
2565
|
+
<CharacterCount
|
|
2566
|
+
id={countId}
|
|
2567
|
+
used={length.used}
|
|
2568
|
+
max={maxLength}
|
|
2569
|
+
labels={countText}
|
|
2570
|
+
caption={<FieldCaption parts={hintParts} className="mt-0" />}
|
|
2571
|
+
/>
|
|
2572
|
+
) : null;
|
|
2573
|
+
const below = fieldBelow(hintParts, counter, errorEl);
|
|
2574
|
+
const onChange = counting
|
|
2575
|
+
? (e: ChangeEvent<HTMLTextAreaElement>) => {
|
|
2576
|
+
length.track(e.target.value);
|
|
2577
|
+
rest.onChange?.(e);
|
|
2578
|
+
}
|
|
2579
|
+
: rest.onChange;
|
|
2226
2580
|
if (label === undefined) {
|
|
2581
|
+
const endHint = hintParts.labelHint;
|
|
2227
2582
|
return (
|
|
2228
|
-
<FieldGroup errorEl={
|
|
2229
|
-
<
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2583
|
+
<FieldGroup errorEl={below} reserve={reserve}>
|
|
2584
|
+
<EndHintRow hint={endHint} className={className}>
|
|
2585
|
+
<textarea
|
|
2586
|
+
ref={ref}
|
|
2587
|
+
id={id}
|
|
2588
|
+
placeholder={placeholder}
|
|
2589
|
+
{...rest}
|
|
2590
|
+
aria-invalid={isInvalid || rest["aria-invalid"] || undefined}
|
|
2591
|
+
aria-describedby={describedBy}
|
|
2592
|
+
onChange={onChange}
|
|
2593
|
+
className={cn(FIELD_BASE, endHint === undefined && className, isInvalid && FIELD_INVALID)}
|
|
2594
|
+
/>
|
|
2595
|
+
</EndHintRow>
|
|
2238
2596
|
</FieldGroup>
|
|
2239
2597
|
);
|
|
2240
2598
|
}
|
|
2599
|
+
const ownPlaceholder = hasPlaceholderText(placeholder);
|
|
2241
2600
|
return (
|
|
2242
|
-
<FieldGroup errorEl={
|
|
2243
|
-
<FloatingField className={className} htmlFor={fieldId} label={label}>
|
|
2601
|
+
<FieldGroup errorEl={below} reserve={reserve}>
|
|
2602
|
+
<FloatingField className={className} htmlFor={fieldId} label={label} hint={hintParts.labelHint}>
|
|
2244
2603
|
<textarea
|
|
2245
2604
|
ref={ref}
|
|
2246
2605
|
id={fieldId}
|
|
2247
|
-
placeholder
|
|
2606
|
+
// The float trick's single space, unless the caller has a placeholder of its
|
|
2607
|
+
// own — then that, shown on focus once the label is out of its way (kastlan
|
|
2608
|
+
// 8, see FLOATING_PLACEHOLDER_ON_FOCUS).
|
|
2609
|
+
placeholder={ownPlaceholder ? placeholder : " "}
|
|
2248
2610
|
{...rest}
|
|
2249
2611
|
aria-invalid={isInvalid || rest["aria-invalid"] || undefined}
|
|
2250
2612
|
aria-describedby={describedBy}
|
|
2251
|
-
|
|
2613
|
+
onChange={onChange}
|
|
2614
|
+
className={cn(
|
|
2615
|
+
FLOATING_INPUT_CLASS,
|
|
2616
|
+
ownPlaceholder && FLOATING_PLACEHOLDER_ON_FOCUS,
|
|
2617
|
+
isInvalid && FIELD_INVALID,
|
|
2618
|
+
)}
|
|
2252
2619
|
/>
|
|
2253
2620
|
</FloatingField>
|
|
2254
2621
|
</FieldGroup>
|