@astryxdesign/core 0.7.5-canary.c60f571 → 0.7.5-canary.d4f09c2
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/Field/inputDisabledFocus.d.ts +14 -0
- package/dist/Field/inputDisabledFocus.d.ts.map +1 -0
- package/dist/Field/inputDisabledFocus.js +19 -0
- package/dist/SideNav/SideNavHeading.d.ts.map +1 -1
- package/dist/SideNav/SideNavHeading.js +34 -0
- package/dist/TextArea/TextArea.d.ts +6 -4
- package/dist/TextArea/TextArea.d.ts.map +1 -1
- package/dist/TextArea/TextArea.js +14 -11
- package/dist/TextInput/TextInput.d.ts +6 -4
- package/dist/TextInput/TextInput.d.ts.map +1 -1
- package/dist/TextInput/TextInput.js +20 -11
- package/package.json +3 -3
- package/src/Field/inputDisabledFocus.ts +28 -0
- package/src/SideNav/SideNav.test.tsx +15 -6
- package/src/SideNav/SideNavHeading.tsx +41 -0
- package/src/TextArea/TextArea.doc.mjs +6 -6
- package/src/TextArea/TextArea.test.tsx +65 -6
- package/src/TextArea/TextArea.tsx +26 -14
- package/src/TextInput/TextInput.doc.mjs +8 -8
- package/src/TextInput/TextInput.test.tsx +72 -6
- package/src/TextInput/TextInput.tsx +32 -14
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file inputDisabledFocus.ts
|
|
3
|
+
* @input Uses StyleX and theme tokens
|
|
4
|
+
* @output inputDisabledFocusStyles (internal; not re-exported from Field/index.ts)
|
|
5
|
+
* @position Field wrapper style shared by TextInput and TextArea
|
|
6
|
+
*/
|
|
7
|
+
import * as stylex from '@stylexjs/stylex';
|
|
8
|
+
export declare const inputDisabledFocusStyles: Readonly<{
|
|
9
|
+
readonly ring: Readonly<{
|
|
10
|
+
readonly borderColor: stylex.StyleXClassNameFor<"borderColor", "light-dark(#0064E0, #2694FE)" | "light-dark(#CCD3DB, #494D53)">;
|
|
11
|
+
readonly boxShadow: stylex.StyleXClassNameFor<"boxShadow", "none" | `inset 0px 0px 0px 2px ${stylex.StyleXVar<"light-dark(#0082FB33, #0082FB3F)">}`>;
|
|
12
|
+
}>;
|
|
13
|
+
}>;
|
|
14
|
+
//# sourceMappingURL=inputDisabledFocus.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"inputDisabledFocus.d.ts","sourceRoot":"","sources":["../../src/Field/inputDisabledFocus.ts"],"names":[],"mappings":"AAEA;;;;;GAKG;AAEH,OAAO,KAAK,MAAM,MAAM,kBAAkB,CAAC;AAG3C,eAAO,MAAM,wBAAwB;;;;;EAenC,CAAC"}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file inputDisabledFocus.ts
|
|
5
|
+
* @input Uses StyleX and theme tokens
|
|
6
|
+
* @output inputDisabledFocusStyles (internal; not re-exported from Field/index.ts)
|
|
7
|
+
* @position Field wrapper style shared by TextInput and TextArea
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import * as stylex from '@stylexjs/stylex';
|
|
11
|
+
import "../theme/tokens.stylex.js";
|
|
12
|
+
import { colorVars } from "../theme/tokens.stylex.js";
|
|
13
|
+
export const inputDisabledFocusStyles = {
|
|
14
|
+
ring: {
|
|
15
|
+
kVAM5u: "xvy26l8 x16it14f",
|
|
16
|
+
kGVxlE: "x1gnnqk1 xwmge8v",
|
|
17
|
+
$$css: true
|
|
18
|
+
}
|
|
19
|
+
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"SideNavHeading.d.ts","sourceRoot":"","sources":["../../src/SideNav/SideNavHeading.tsx"],"names":[],"mappings":"AAIA;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,EAAkB,KAAK,SAAS,EAAC,MAAM,OAAO,CAAC;AAsBtD,OAAO,KAAK,EAAC,iBAAiB,EAAC,MAAM,eAAe,CAAC;AAGrD,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,cAAc,CAAC;
|
|
1
|
+
{"version":3,"file":"SideNavHeading.d.ts","sourceRoot":"","sources":["../../src/SideNav/SideNavHeading.tsx"],"names":[],"mappings":"AAIA;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,EAAkB,KAAK,SAAS,EAAC,MAAM,OAAO,CAAC;AAsBtD,OAAO,KAAK,EAAC,iBAAiB,EAAC,MAAM,eAAe,CAAC;AAGrD,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,cAAc,CAAC;AAsO5C,MAAM,WAAW,mBAAoB,SAAQ,SAAS,CAAC,cAAc,CAAC;IACpE,wCAAwC;IACxC,GAAG,CAAC,EAAE,KAAK,CAAC,GAAG,CAAC,cAAc,CAAC,CAAC;IAChC;;OAEG;IACH,IAAI,CAAC,EAAE,SAAS,CAAC;IACjB;;;;OAIG;IACH,EAAE,CAAC,EAAE,iBAAiB,CAAC;IACvB;;OAEG;IACH,OAAO,EAAE,MAAM,CAAC;IAChB;;OAEG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;OAEG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB;;OAEG;IACH,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B;;OAEG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;OAEG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;OAGG;IACH,gBAAgB,CAAC,EAAE,SAAS,CAAC;IAC7B;;;;;;OAMG;IACH,IAAI,CAAC,EAAE,SAAS,CAAC;CAClB;AAQD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,wBAAgB,cAAc,CAAC,EAC7B,EAAE,EACF,IAAI,EACJ,OAAO,EACP,WAAW,EACX,YAAY,EACZ,gBAAgB,EAChB,UAAU,EACV,cAAc,EACd,gBAAgB,EAChB,IAAI,EACJ,MAAM,EACN,SAAS,EACT,KAAK,EACL,aAAa,EAAE,MAAM,EACrB,GAAG,EACH,GAAG,KAAK,EACT,EAAE,mBAAmB,sCAgiBrB;yBAjjBe,cAAc"}
|
|
@@ -129,6 +129,17 @@ const styles = {
|
|
|
129
129
|
kGuDYH: "x1qlqyl8",
|
|
130
130
|
$$css: true
|
|
131
131
|
},
|
|
132
|
+
linkBesideEndContent: {
|
|
133
|
+
k1xSpc: "x78zum5",
|
|
134
|
+
kGNEyG: "x6s0dn4",
|
|
135
|
+
kSGwAc: "xkh2ocl",
|
|
136
|
+
kOIVth: "x1txdalj",
|
|
137
|
+
kzQI83: "x1iyjqo2",
|
|
138
|
+
k7Eaqz: "xeuugli",
|
|
139
|
+
kybGjl: "x1hl2dhg",
|
|
140
|
+
kMwMTN: "x1heor9g",
|
|
141
|
+
$$css: true
|
|
142
|
+
},
|
|
132
143
|
popoverHeading: {
|
|
133
144
|
k1xSpc: "x78zum5",
|
|
134
145
|
kGNEyG: "x6s0dn4",
|
|
@@ -484,6 +495,29 @@ export function SideNavHeading({
|
|
|
484
495
|
}))]
|
|
485
496
|
});
|
|
486
497
|
|
|
498
|
+
// Whole heading is a link (no menu, single headingHref). End content may
|
|
499
|
+
// be a control, which must not sit inside the link (no interactive content
|
|
500
|
+
// in an <a>), so with end content the row is a div holding the link and the
|
|
501
|
+
// end content side by side, as the mixed mode does.
|
|
502
|
+
if (isWholeHeadingLink && headingHref && headerEndContentElement) {
|
|
503
|
+
return /*#__PURE__*/_jsxs("div", {
|
|
504
|
+
ref: ref,
|
|
505
|
+
"data-testid": testId,
|
|
506
|
+
...mergeProps(themeProps('side-nav-heading'), stylex.props(styles.root, xstyle), className, style),
|
|
507
|
+
children: [/*#__PURE__*/_jsxs(LinkComponent, {
|
|
508
|
+
href: headingHref,
|
|
509
|
+
...focusOutlineProps.focusVisible(styles.linkBesideEndContent, styles.menuTrigger),
|
|
510
|
+
...props,
|
|
511
|
+
children: [icon && /*#__PURE__*/_jsx("span", {
|
|
512
|
+
...{
|
|
513
|
+
className: "x2lah0s x78zum5 x6s0dn4 xl56j7k"
|
|
514
|
+
},
|
|
515
|
+
children: icon
|
|
516
|
+
}), renderTextContent()]
|
|
517
|
+
}), headerEndContentElement]
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
|
|
487
521
|
// Whole heading is a link (no menu, single headingHref)
|
|
488
522
|
if (isWholeHeadingLink && headingHref) {
|
|
489
523
|
return /*#__PURE__*/_jsxs(LinkComponent, {
|
|
@@ -76,7 +76,9 @@ export interface TextAreaProps extends Omit<FieldInputBaseProps, 'onChange' | 'd
|
|
|
76
76
|
*/
|
|
77
77
|
rows?: number;
|
|
78
78
|
/**
|
|
79
|
-
* Whether the textarea is disabled.
|
|
79
|
+
* Whether the textarea is disabled. A disabled textarea is dimmed, cannot be
|
|
80
|
+
* edited, and is left out of form submission, but it stays in the tab order
|
|
81
|
+
* (`aria-disabled`, not the native attribute).
|
|
80
82
|
* @default false
|
|
81
83
|
*/
|
|
82
84
|
isDisabled?: boolean;
|
|
@@ -84,7 +86,7 @@ export interface TextAreaProps extends Omit<FieldInputBaseProps, 'onChange' | 'd
|
|
|
84
86
|
* Whether the textarea is read-only.
|
|
85
87
|
* The value is shown at full opacity and still submits with the form, but
|
|
86
88
|
* cannot be edited. Unlike `isDisabled`, a read-only textarea is not dimmed
|
|
87
|
-
* and
|
|
89
|
+
* and keeps its form value — use it for a value the user should see and
|
|
88
90
|
* send but not change. `isDisabled` takes precedence when both are set.
|
|
89
91
|
* @default false
|
|
90
92
|
*/
|
|
@@ -96,8 +98,8 @@ export interface TextAreaProps extends Omit<FieldInputBaseProps, 'onChange' | 'd
|
|
|
96
98
|
* discoverable by keyboard and assistive technology. The field cannot be
|
|
97
99
|
* edited (it becomes read-only) while disabled.
|
|
98
100
|
*
|
|
99
|
-
* Use this instead of wrapping a disabled textarea in `Tooltip
|
|
100
|
-
*
|
|
101
|
+
* Use this instead of wrapping a disabled textarea in `Tooltip`, so the
|
|
102
|
+
* reason is attached to the field itself.
|
|
101
103
|
*
|
|
102
104
|
* @example
|
|
103
105
|
* ```
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"TextArea.d.ts","sourceRoot":"","sources":["../../src/TextArea/TextArea.tsx"],"names":[],"mappings":"AAIA;;;;;;;;;;;;GAYG;AAEH,OAAO,EAOL,KAAK,WAAW,EAChB,KAAK,cAAc,EACnB,KAAK,UAAU,EACf,KAAK,SAAS,EACf,MAAM,OAAO,CAAC;AAQf,OAAO,EAML,KAAK,kBAAkB,EACxB,MAAM,UAAU,CAAC;AAClB,OAAO,EAAuB,KAAK,QAAQ,EAAC,MAAM,SAAS,CAAC;AAK5D,OAAO,EACL,KAAK,mBAAmB,EAEzB,MAAM,qBAAqB,CAAC;
|
|
1
|
+
{"version":3,"file":"TextArea.d.ts","sourceRoot":"","sources":["../../src/TextArea/TextArea.tsx"],"names":[],"mappings":"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"}
|
|
@@ -25,6 +25,7 @@ import { Spinner } from "../Spinner/index.js";
|
|
|
25
25
|
import { useTooltip } from "../Tooltip/index.js";
|
|
26
26
|
import { mergeProps } from "../utils/index.js";
|
|
27
27
|
import { useFieldInputStyling } from "../Field/fieldParts.js";
|
|
28
|
+
import { inputDisabledFocusStyles } from "../Field/inputDisabledFocus.js";
|
|
28
29
|
import { useInputContainer } from "../hooks/useInputContainer.js";
|
|
29
30
|
import { useInputStatusIcon } from "../hooks/useInputStatusIcon.js";
|
|
30
31
|
import { useAnnounce } from "../hooks/useAnnounce.js";
|
|
@@ -178,12 +179,14 @@ export function TextArea({
|
|
|
178
179
|
// (spec:AST-086 FR8).
|
|
179
180
|
useBusyAnnouncement(isBusy, t('@astryx.spinner.loading'), containerRef);
|
|
180
181
|
|
|
181
|
-
// Disabled-reason tooltip.
|
|
182
|
-
// tooltip listeners attach to the textarea container (which already exists)
|
|
182
|
+
// Disabled-reason tooltip. The tooltip listeners attach to the textarea container (which already exists)
|
|
183
183
|
// and the textarea stays perceivable via aria-disabled instead of the native
|
|
184
184
|
// disabled attribute. The field is made read-only so it can't be typed into,
|
|
185
185
|
// and value mutation is blocked by the isDisabled guard in handleChange.
|
|
186
186
|
const showsDisabledMessage = isDisabled && !!disabledMessage;
|
|
187
|
+
// A disabled field keeps its tab stop but, like a natively disabled one,
|
|
188
|
+
// runs no caller event handler other than focus and blur (`spec:AST-085` FR2).
|
|
189
|
+
const fieldProps = isDisabled ? Object.fromEntries(Object.entries(rest).filter(([key]) => !/^on[A-Z]/.test(key) || /^on(Focus|Blur)/.test(key))) : rest;
|
|
187
190
|
const disabledMessageTooltip = useTooltip({
|
|
188
191
|
placement: 'above',
|
|
189
192
|
// The container div is not naturally focusable; focusin bubbles up from
|
|
@@ -297,7 +300,7 @@ export function TextArea({
|
|
|
297
300
|
status: status?.type ?? null,
|
|
298
301
|
disabled: isDisabled ? 'disabled' : null,
|
|
299
302
|
readonly: isReadOnly ? 'readonly' : null
|
|
300
|
-
}), stylex.props(inputWrapperStyles.base, styles.wrapper, isDisabled && inputWrapperStyles.disabled, status && inputStatusBorderStyles[status.type], status && !isDisabled && inputStatusHoverShadowStyles[status.type], status && inputStatusFocusWithinStyles[status.type], controlXstyle), controlClassName, controlStyle),
|
|
303
|
+
}), stylex.props(inputWrapperStyles.base, styles.wrapper, isDisabled && inputWrapperStyles.disabled, isDisabled && inputDisabledFocusStyles.ring, status && inputStatusBorderStyles[status.type], status && !isDisabled && inputStatusHoverShadowStyles[status.type], status && inputStatusFocusWithinStyles[status.type], controlXstyle), controlClassName, controlStyle),
|
|
301
304
|
children: [startIcon && /*#__PURE__*/_jsx("span", {
|
|
302
305
|
...{
|
|
303
306
|
className: "x10l6tqk xctzyg x1kiyvbf x47corl x78zum5"
|
|
@@ -307,24 +310,24 @@ export function TextArea({
|
|
|
307
310
|
color: 'secondary'
|
|
308
311
|
})
|
|
309
312
|
}), /*#__PURE__*/_jsx("textarea", {
|
|
310
|
-
...
|
|
313
|
+
...fieldProps,
|
|
311
314
|
ref: useMergedRefs(ref, textareaRef),
|
|
312
315
|
id: id,
|
|
313
316
|
name: isDisabled ? undefined : htmlName,
|
|
314
317
|
value: optimisticValue,
|
|
315
318
|
onChange: handleChange,
|
|
316
|
-
onPaste: onPaste,
|
|
319
|
+
onPaste: isDisabled ? undefined : onPaste,
|
|
317
320
|
onFocus: onFocus,
|
|
318
321
|
onBlur: onBlur,
|
|
319
322
|
placeholder: placeholder,
|
|
320
323
|
rows: rows
|
|
321
|
-
//
|
|
322
|
-
// aria-disabled
|
|
323
|
-
// handleChange guard
|
|
324
|
+
// A disabled textarea keeps its place in the tab order
|
|
325
|
+
// (spec:AST-085): aria-disabled instead of the native attribute,
|
|
326
|
+
// with readOnly + the handleChange guard keeping the value from
|
|
327
|
+
// changing.
|
|
324
328
|
,
|
|
325
|
-
disabled: isDisabled
|
|
326
|
-
|
|
327
|
-
readOnly: isReadOnly || showsDisabledMessage || undefined,
|
|
329
|
+
"aria-disabled": isDisabled ? 'true' : undefined,
|
|
330
|
+
readOnly: isReadOnly || isDisabled || undefined,
|
|
328
331
|
spellCheck: hasSpellCheck,
|
|
329
332
|
autoFocus: hasAutoFocus,
|
|
330
333
|
"data-autofocus": hasAutoFocus || undefined,
|
|
@@ -63,7 +63,9 @@ export interface TextInputProps extends Omit<FieldInputBaseProps, 'onChange' | '
|
|
|
63
63
|
*/
|
|
64
64
|
isRequired?: boolean;
|
|
65
65
|
/**
|
|
66
|
-
* Whether the input is disabled.
|
|
66
|
+
* Whether the input is disabled. A disabled input is dimmed, cannot be
|
|
67
|
+
* edited, and is left out of form submission, but it stays in the tab order
|
|
68
|
+
* (`aria-disabled`, not the native attribute).
|
|
67
69
|
* @default false
|
|
68
70
|
*/
|
|
69
71
|
isDisabled?: boolean;
|
|
@@ -71,7 +73,7 @@ export interface TextInputProps extends Omit<FieldInputBaseProps, 'onChange' | '
|
|
|
71
73
|
* Whether the input is read-only.
|
|
72
74
|
* The value is shown at full opacity and still submits with the form, but
|
|
73
75
|
* cannot be edited. Unlike `isDisabled`, a read-only input is not dimmed and
|
|
74
|
-
*
|
|
76
|
+
* keeps its form value — use it for a value the user should see and send
|
|
75
77
|
* but not change. `isDisabled` takes precedence when both are set.
|
|
76
78
|
* @default false
|
|
77
79
|
*/
|
|
@@ -83,8 +85,8 @@ export interface TextInputProps extends Omit<FieldInputBaseProps, 'onChange' | '
|
|
|
83
85
|
* keyboard and assistive technology. The field cannot be edited (it becomes
|
|
84
86
|
* read-only) while disabled.
|
|
85
87
|
*
|
|
86
|
-
* Use this instead of wrapping a disabled input in `Tooltip
|
|
87
|
-
*
|
|
88
|
+
* Use this instead of wrapping a disabled input in `Tooltip`, so the reason
|
|
89
|
+
* is attached to the field itself.
|
|
88
90
|
*
|
|
89
91
|
* @example
|
|
90
92
|
* ```
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"TextInput.d.ts","sourceRoot":"","sources":["../../src/TextInput/TextInput.tsx"],"names":[],"mappings":"AAIA;;;;;;;;;;;;GAYG;AAEH,OAAO,EAML,KAAK,WAAW,EAChB,KAAK,aAAa,EAClB,KAAK,SAAS,EACf,MAAM,OAAO,CAAC;AACf,OAAO,KAAK,MAAM,MAAM,kBAAkB,CAAC;AAO3C,OAAO,EAGL,KAAK,WAAW,EAKhB,KAAK,kBAAkB,EACxB,MAAM,UAAU,CAAC;AAClB,OAAO,EAAiB,KAAK,QAAQ,EAAC,MAAM,SAAS,CAAC;AAqCtD,QAAA,MAAM,UAAU;;;;;;;;;;EAUd,CAAC;AAEH,MAAM,MAAM,aAAa,GAAG,MAAM,OAAO,UAAU,CAAC;AAMpD,YAAY,EACV,WAAW,IAAI,eAAe,EAC9B,eAAe,IAAI,mBAAmB,GACvC,MAAM,UAAU,CAAC;AAQlB,OAAO,EACL,KAAK,mBAAmB,EAEzB,MAAM,qBAAqB,CAAC;
|
|
1
|
+
{"version":3,"file":"TextInput.d.ts","sourceRoot":"","sources":["../../src/TextInput/TextInput.tsx"],"names":[],"mappings":"AAIA;;;;;;;;;;;;GAYG;AAEH,OAAO,EAML,KAAK,WAAW,EAChB,KAAK,aAAa,EAClB,KAAK,SAAS,EACf,MAAM,OAAO,CAAC;AACf,OAAO,KAAK,MAAM,MAAM,kBAAkB,CAAC;AAO3C,OAAO,EAGL,KAAK,WAAW,EAKhB,KAAK,kBAAkB,EACxB,MAAM,UAAU,CAAC;AAClB,OAAO,EAAiB,KAAK,QAAQ,EAAC,MAAM,SAAS,CAAC;AAqCtD,QAAA,MAAM,UAAU;;;;;;;;;;EAUd,CAAC;AAEH,MAAM,MAAM,aAAa,GAAG,MAAM,OAAO,UAAU,CAAC;AAMpD,YAAY,EACV,WAAW,IAAI,eAAe,EAC9B,eAAe,IAAI,mBAAmB,GACvC,MAAM,UAAU,CAAC;AAQlB,OAAO,EACL,KAAK,mBAAmB,EAEzB,MAAM,qBAAqB,CAAC;AAE7B,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,gBAAgB,CAAC;AAM9C,MAAM,MAAM,aAAa,GAAG,MAAM,GAAG,UAAU,GAAG,OAAO,CAAC;AAE1D,MAAM,WAAW,cAAe,SAAQ,IAAI,CAC1C,mBAAmB,EACnB,UAAU,GAAG,cAAc,CAC5B;IACC,wCAAwC;IACxC,GAAG,CAAC,EAAE,KAAK,CAAC,GAAG,CAAC,gBAAgB,CAAC,CAAC;IAClC;;;OAGG;IACH,IAAI,CAAC,EAAE,aAAa,CAAC;IACrB;;OAEG;IACH,KAAK,EAAE,MAAM,CAAC;IACd;;;OAGG;IACH,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB;;OAEG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;OAGG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB;;;OAGG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB;;;;;OAKG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB;;;;;;;OAOG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB;;;;;;;;;;;;;;;;;;;OAmBG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB;;;OAGG;IACH,SAAS,CAAC,EAAE,SAAS,GAAG,QAAQ,CAAC;IACjC;;;;OAIG;IACH,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB;;;;;;OAMG;IACH,aAAa,CAAC,EAAE,kBAAkB,CAAC;IACnC;;;;;OAKG;IACH,IAAI,CAAC,EAAE,aAAa,CAAC;IACrB;;OAEG;IACH,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,CAAC,EAAE,WAAW,CAAC,gBAAgB,CAAC,KAAK,IAAI,CAAC;IACrE,qEAAqE;IACrE,YAAY,CAAC,EAAE,CACb,KAAK,EAAE,MAAM,EACb,CAAC,EAAE,WAAW,CAAC,gBAAgB,CAAC,KAC7B,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC1B,8DAA8D;IAC9D,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB;;OAEG;IACH,KAAK,EAAE,MAAM,CAAC;IACd;;OAEG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;;OAIG;IACH,KAAK,CAAC,EAAE,SAAS,CAAC;IAClB;;OAEG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB;;;;OAIG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB;;;OAGG;IACH,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB;;;OAGG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;OAEG;IACH,OAAO,CAAC,EAAE,MAAM,IAAI,CAAC;IACrB;;OAEG;IACH,SAAS,CAAC,EAAE,CAAC,CAAC,EAAE,aAAa,CAAC,gBAAgB,CAAC,KAAK,IAAI,CAAC;IACzD;;;;;OAKG;IACH,YAAY,CAAC,EAAE,KAAK,CAAC,mBAAmB,CAAC,gBAAgB,CAAC,CAAC,cAAc,CAAC,CAAC;CAC5E;AAED;;;;;;;;GAQG;AACH,wBAAgB,SAAS,CAAC,EACxB,IAAa,EACb,KAAK,EACL,aAAqB,EACrB,WAAW,EACX,UAAkB,EAClB,UAAkB,EAClB,UAAkB,EAClB,UAAkB,EAClB,eAAe,EACf,SAAS,EACT,MAAM,EACN,aAA0B,EAC1B,IAAI,EAAE,QAAQ,EACd,QAAQ,EACR,YAAY,EACZ,SAAiB,EACjB,KAAK,EACL,WAAW,EACX,YAAY,EACZ,QAAgB,EAChB,YAAoB,EACpB,QAAQ,EACR,OAAO,EACP,SAAS,EACT,KAAK,EACL,eAAe,EACf,kBAAkB,EAClB,cAAc,EACd,aAAa,EACb,gBAAgB,EAChB,YAAY,EACZ,GAAG,EACH,EAAE,EAAE,MAAM,EACV,kBAAkB,EAAE,mBAAmB,EACvC,GAAG,SAAS,EACb,EAAE,cAAc,+BAqPhB;yBAzRe,SAAS"}
|
|
@@ -50,6 +50,7 @@ import { useInputStatusIcon } from "../hooks/useInputStatusIcon.js";
|
|
|
50
50
|
import { useResolvedRequired } from "../hooks/useResolvedRequired.js";
|
|
51
51
|
import { useInputGroup } from "../InputGroup/InputGroupContext.js";
|
|
52
52
|
import { useFieldInputStyling } from "../Field/fieldParts.js";
|
|
53
|
+
import { inputDisabledFocusStyles } from "../Field/inputDisabledFocus.js";
|
|
53
54
|
import { themeProps } from "../utils/themeProps.js";
|
|
54
55
|
import { useTranslator } from "../i18n/index.js";
|
|
55
56
|
import { useMergedRefs } from "../hooks/useMergedRefs.js";
|
|
@@ -114,6 +115,9 @@ export function TextInput({
|
|
|
114
115
|
isRequired,
|
|
115
116
|
isOptional
|
|
116
117
|
});
|
|
118
|
+
// A disabled field keeps its tab stop but, like a natively disabled one,
|
|
119
|
+
// runs no caller event handler other than focus and blur (`spec:AST-085` FR2).
|
|
120
|
+
const fieldProps = isDisabled ? Object.fromEntries(Object.entries(rest).filter(([key]) => !/^on[A-Z]/.test(key) || /^on(Focus|Blur)/.test(key))) : rest;
|
|
117
121
|
const size = useSize(sizeProp, 'md');
|
|
118
122
|
const generatedID = useId();
|
|
119
123
|
const id = idProp ?? generatedID;
|
|
@@ -130,8 +134,7 @@ export function TextInput({
|
|
|
130
134
|
// (spec:AST-086 FR8).
|
|
131
135
|
useBusyAnnouncement(isBusy, t('@astryx.spinner.loading'), containerRef);
|
|
132
136
|
|
|
133
|
-
// Disabled-reason tooltip.
|
|
134
|
-
// tooltip listeners attach to the input container (which already exists) and
|
|
137
|
+
// Disabled-reason tooltip. The tooltip listeners attach to the input container (which already exists) and
|
|
135
138
|
// the input stays perceivable via aria-disabled instead of the native
|
|
136
139
|
// disabled attribute. The field is made read-only so it can't be typed into,
|
|
137
140
|
// and value mutation is blocked by the isDisabled guard in handleChange.
|
|
@@ -218,7 +221,7 @@ export function TextInput({
|
|
|
218
221
|
status: status?.type ?? null,
|
|
219
222
|
disabled: isDisabled ? 'disabled' : null,
|
|
220
223
|
readonly: isReadOnly ? 'readonly' : null
|
|
221
|
-
}), stylex.props(inputWrapperStyles.base, sizeStyles[size], isDisabled && inputWrapperStyles.disabled, status && inputStatusBorderStyles[status.type], status && !isDisabled && inputStatusHoverShadowStyles[status.type], status && inputStatusFocusWithinStyles[status.type], inputGroup && groupStyles.inGroup, controlXstyle), controlClassName, controlStyle),
|
|
224
|
+
}), stylex.props(inputWrapperStyles.base, sizeStyles[size], isDisabled && inputWrapperStyles.disabled, isDisabled && inputDisabledFocusStyles.ring, status && inputStatusBorderStyles[status.type], status && !isDisabled && inputStatusHoverShadowStyles[status.type], status && inputStatusFocusWithinStyles[status.type], inputGroup && groupStyles.inGroup, controlXstyle), controlClassName, controlStyle),
|
|
222
225
|
children: [startIcon && renderIconSlot(startIcon, {
|
|
223
226
|
size: 'sm',
|
|
224
227
|
color: 'secondary'
|
|
@@ -226,14 +229,21 @@ export function TextInput({
|
|
|
226
229
|
id: inputLabelID,
|
|
227
230
|
children: label
|
|
228
231
|
}), /*#__PURE__*/_jsx("input", {
|
|
229
|
-
...
|
|
232
|
+
...fieldProps,
|
|
230
233
|
ref: useMergedRefs(ref, inputRef),
|
|
231
234
|
id: id,
|
|
232
235
|
name: isDisabled ? undefined : htmlName,
|
|
233
236
|
type: type,
|
|
234
237
|
value: optimisticValue,
|
|
235
238
|
onChange: handleChange,
|
|
236
|
-
onKeyDown: onEnter || onKeyDown ? e => {
|
|
239
|
+
onKeyDown: isDisabled || onEnter || onKeyDown ? e => {
|
|
240
|
+
if (isDisabled) {
|
|
241
|
+
// A read-only field still submits its form on Enter.
|
|
242
|
+
if (e.key === 'Enter') {
|
|
243
|
+
e.preventDefault();
|
|
244
|
+
}
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
237
247
|
// The composing keydown fires before compositionend, so without
|
|
238
248
|
// this guard pressing Enter to commit a Japanese/Chinese/Korean
|
|
239
249
|
// IME conversion would trigger onEnter (submit/save actions)
|
|
@@ -245,13 +255,12 @@ export function TextInput({
|
|
|
245
255
|
onKeyDown?.(e);
|
|
246
256
|
} : undefined,
|
|
247
257
|
placeholder: placeholder
|
|
248
|
-
//
|
|
249
|
-
//
|
|
250
|
-
//
|
|
258
|
+
// A disabled input keeps its place in the tab order (spec:AST-085):
|
|
259
|
+
// aria-disabled instead of the native attribute, with readOnly + the
|
|
260
|
+
// handleChange guard keeping the value from changing.
|
|
251
261
|
,
|
|
252
|
-
disabled: isDisabled
|
|
253
|
-
|
|
254
|
-
readOnly: isReadOnly || showsDisabledMessage || undefined,
|
|
262
|
+
"aria-disabled": isDisabled ? 'true' : undefined,
|
|
263
|
+
readOnly: isReadOnly || isDisabled || undefined,
|
|
255
264
|
autoFocus: hasAutoFocus,
|
|
256
265
|
"data-autofocus": hasAutoFocus || undefined,
|
|
257
266
|
"aria-describedby": [ariaDescribedByProp, ariaDescribedBy].filter(Boolean).join(' ') || undefined,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@astryxdesign/core",
|
|
3
|
-
"version": "0.7.5-canary.
|
|
3
|
+
"version": "0.7.5-canary.d4f09c2",
|
|
4
4
|
"displayName": "Astryx Core",
|
|
5
5
|
"description": "The component library. Accessible, themeable React components with built-in spacing, dark mode, and StyleX styling.",
|
|
6
6
|
"author": "Meta Open Source",
|
|
@@ -792,8 +792,8 @@
|
|
|
792
792
|
"react-dom": ">=19.0.0"
|
|
793
793
|
},
|
|
794
794
|
"devDependencies": {
|
|
795
|
-
"@astryxdesign/a11y-spec": "0.7.5-canary.
|
|
796
|
-
"@astryxdesign/cli": "0.7.5-canary.
|
|
795
|
+
"@astryxdesign/a11y-spec": "0.7.5-canary.d4f09c2",
|
|
796
|
+
"@astryxdesign/cli": "0.7.5-canary.d4f09c2",
|
|
797
797
|
"@babel/cli": "^7.29.7",
|
|
798
798
|
"@babel/core": "^7.29.7",
|
|
799
799
|
"@babel/preset-react": "^7.29.7",
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file inputDisabledFocus.ts
|
|
5
|
+
* @input Uses StyleX and theme tokens
|
|
6
|
+
* @output inputDisabledFocusStyles (internal; not re-exported from Field/index.ts)
|
|
7
|
+
* @position Field wrapper style shared by TextInput and TextArea
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import * as stylex from '@stylexjs/stylex';
|
|
11
|
+
import {colorVars} from '../theme/tokens.stylex';
|
|
12
|
+
|
|
13
|
+
export const inputDisabledFocusStyles = stylex.create({
|
|
14
|
+
// The shared disabled style drops the focus ring; restore it while the field's
|
|
15
|
+
// own control, a direct child, has keyboard focus (`spec:AST-085` FR6), so a
|
|
16
|
+
// focused status button keeps only its own outline.
|
|
17
|
+
ring: {
|
|
18
|
+
borderColor: {
|
|
19
|
+
default: colorVars['--color-border-emphasized'],
|
|
20
|
+
':has(> [aria-disabled="true"]:focus-visible)':
|
|
21
|
+
colorVars['--color-accent'],
|
|
22
|
+
},
|
|
23
|
+
boxShadow: {
|
|
24
|
+
default: 'none',
|
|
25
|
+
':has(> [aria-disabled="true"]:focus-visible)': `inset 0px 0px 0px 2px ${colorVars['--color-accent-muted']}`,
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
});
|
|
@@ -668,18 +668,27 @@ describe('SideNavHeading headerEndContent', () => {
|
|
|
668
668
|
expect(screen.getByTestId('end-badge')).toBeInTheDocument();
|
|
669
669
|
});
|
|
670
670
|
|
|
671
|
-
it('renders headerEndContent
|
|
671
|
+
it('renders headerEndContent beside the link, not inside it, in isWholeHeadingLink path', () => {
|
|
672
|
+
const onPress = vi.fn();
|
|
672
673
|
render(
|
|
673
674
|
<SideNavHeading
|
|
674
675
|
heading="My App"
|
|
675
676
|
headingHref="/home"
|
|
676
|
-
headerEndContent={
|
|
677
|
+
headerEndContent={
|
|
678
|
+
<button type="button" onClick={onPress}>
|
|
679
|
+
Create
|
|
680
|
+
</button>
|
|
681
|
+
}
|
|
677
682
|
/>,
|
|
678
683
|
);
|
|
679
|
-
const
|
|
680
|
-
expect(
|
|
681
|
-
//
|
|
682
|
-
|
|
684
|
+
const link = screen.getByRole('link', {name: 'My App'});
|
|
685
|
+
expect(link).toHaveAttribute('href', '/home');
|
|
686
|
+
// A control inside a link is invalid and its press would follow the link.
|
|
687
|
+
const button = screen.getByRole('button', {name: 'Create'});
|
|
688
|
+
expect(button.closest('a')).toBeNull();
|
|
689
|
+
expect(link.parentElement).toBe(button.parentElement?.parentElement);
|
|
690
|
+
fireEvent.click(button);
|
|
691
|
+
expect(onPress).toHaveBeenCalledTimes(1);
|
|
683
692
|
});
|
|
684
693
|
|
|
685
694
|
it('renders headerEndContent in isWholeHeadingTrigger path', () => {
|
|
@@ -189,6 +189,17 @@ const styles = stylex.create({
|
|
|
189
189
|
// today. The 28px min box still wins over Icon's width/height.
|
|
190
190
|
fontSize: 'inherit',
|
|
191
191
|
},
|
|
192
|
+
// The link part of a whole-heading link row that also has end content.
|
|
193
|
+
linkBesideEndContent: {
|
|
194
|
+
display: 'flex',
|
|
195
|
+
alignItems: 'center',
|
|
196
|
+
alignSelf: 'stretch',
|
|
197
|
+
gap: spacingVars['--spacing-2'],
|
|
198
|
+
flexGrow: 1,
|
|
199
|
+
minWidth: 0,
|
|
200
|
+
textDecoration: 'none',
|
|
201
|
+
color: 'inherit',
|
|
202
|
+
},
|
|
192
203
|
headerEndContent: {
|
|
193
204
|
flexShrink: 0,
|
|
194
205
|
display: 'flex',
|
|
@@ -640,6 +651,36 @@ export function SideNavHeading({
|
|
|
640
651
|
</button>
|
|
641
652
|
);
|
|
642
653
|
|
|
654
|
+
// Whole heading is a link (no menu, single headingHref). End content may
|
|
655
|
+
// be a control, which must not sit inside the link (no interactive content
|
|
656
|
+
// in an <a>), so with end content the row is a div holding the link and the
|
|
657
|
+
// end content side by side, as the mixed mode does.
|
|
658
|
+
if (isWholeHeadingLink && headingHref && headerEndContentElement) {
|
|
659
|
+
return (
|
|
660
|
+
<div
|
|
661
|
+
ref={ref}
|
|
662
|
+
data-testid={testId}
|
|
663
|
+
{...mergeProps(
|
|
664
|
+
themeProps('side-nav-heading'),
|
|
665
|
+
stylex.props(styles.root, xstyle),
|
|
666
|
+
className,
|
|
667
|
+
style,
|
|
668
|
+
)}>
|
|
669
|
+
<LinkComponent
|
|
670
|
+
href={headingHref}
|
|
671
|
+
{...focusOutlineProps.focusVisible(
|
|
672
|
+
styles.linkBesideEndContent,
|
|
673
|
+
styles.menuTrigger,
|
|
674
|
+
)}
|
|
675
|
+
{...(props as React.AnchorHTMLAttributes<HTMLAnchorElement>)}>
|
|
676
|
+
{icon && <span {...stylex.props(styles.icon)}>{icon}</span>}
|
|
677
|
+
{renderTextContent()}
|
|
678
|
+
</LinkComponent>
|
|
679
|
+
{headerEndContentElement}
|
|
680
|
+
</div>
|
|
681
|
+
);
|
|
682
|
+
}
|
|
683
|
+
|
|
643
684
|
// Whole heading is a link (no menu, single headingHref)
|
|
644
685
|
if (isWholeHeadingLink && headingHref) {
|
|
645
686
|
return (
|
|
@@ -151,14 +151,14 @@ export const docs = {
|
|
|
151
151
|
name: 'isReadOnly',
|
|
152
152
|
type: 'boolean',
|
|
153
153
|
description:
|
|
154
|
-
'Makes the textarea read-only: the value is shown at full opacity and still submits with the form, but cannot be edited. Unlike isDisabled, a read-only textarea is not dimmed and
|
|
154
|
+
'Makes the textarea read-only: the value is shown at full opacity and still submits with the form, but cannot be edited. Unlike isDisabled, a read-only textarea is not dimmed and keeps its form value. isDisabled takes precedence when both are set.',
|
|
155
155
|
default: 'false',
|
|
156
156
|
},
|
|
157
157
|
{
|
|
158
158
|
name: 'disabledMessage',
|
|
159
159
|
type: 'string',
|
|
160
160
|
description:
|
|
161
|
-
'Explains why the textarea is disabled. With isDisabled, shows a tooltip on hover/keyboard focus and keeps the textarea focusable via aria-disabled (the field becomes read-only). Use this instead of wrapping a disabled TextArea in Tooltip
|
|
161
|
+
'Explains why the textarea is disabled. With isDisabled, shows a tooltip on hover/keyboard focus and keeps the textarea focusable via aria-disabled (the field becomes read-only). Use this instead of wrapping a disabled TextArea in Tooltip, so the reason is attached to the field itself.',
|
|
162
162
|
},
|
|
163
163
|
{
|
|
164
164
|
name: 'isLoading',
|
|
@@ -364,7 +364,7 @@ export const docs = {
|
|
|
364
364
|
{
|
|
365
365
|
guidance: false,
|
|
366
366
|
description:
|
|
367
|
-
"Don't wrap a disabled TextArea in Tooltip to explain why it's disabled
|
|
367
|
+
"Don't wrap a disabled TextArea in Tooltip to explain why it's disabled. Use the disabledMessage prop, which attaches the reason to the field itself.",
|
|
368
368
|
},
|
|
369
369
|
],
|
|
370
370
|
},
|
|
@@ -437,7 +437,7 @@ export const docsZh = {
|
|
|
437
437
|
name: 'isReadOnly',
|
|
438
438
|
type: 'boolean',
|
|
439
439
|
description:
|
|
440
|
-
'将文本域设为只读:值以完整不透明度显示并仍随表单提交,但无法编辑。与 isDisabled
|
|
440
|
+
'将文本域设为只读:值以完整不透明度显示并仍随表单提交,但无法编辑。与 isDisabled 不同,只读文本域不会变暗,并保留其表单值。同时设置时 isDisabled 优先。',
|
|
441
441
|
default: 'false',
|
|
442
442
|
},
|
|
443
443
|
{
|
|
@@ -637,7 +637,7 @@ export const docsZh = {
|
|
|
637
637
|
{
|
|
638
638
|
guidance: false,
|
|
639
639
|
description:
|
|
640
|
-
"Don't wrap a disabled TextArea in Tooltip to explain why it's disabled
|
|
640
|
+
"Don't wrap a disabled TextArea in Tooltip to explain why it's disabled. Use the disabledMessage prop, which attaches the reason to the field itself.",
|
|
641
641
|
},
|
|
642
642
|
],
|
|
643
643
|
},
|
|
@@ -710,7 +710,7 @@ export const docsDense = {
|
|
|
710
710
|
'Shows "Required" indicator+sets aria-required. Mutually exclusive w/ isOptional.',
|
|
711
711
|
isDisabled: 'Disables textarea, prevents interaction.',
|
|
712
712
|
isReadOnly:
|
|
713
|
-
'Read-only: value visible + still submits, but not editable. Unlike isDisabled: not dimmed
|
|
713
|
+
'Read-only: value visible + still submits, but not editable. Unlike isDisabled: not dimmed.',
|
|
714
714
|
disabledMessage:
|
|
715
715
|
'Explains why textarea is disabled. With isDisabled, shows tooltip on hover/focus + keeps textarea focusable via aria-disabled (field becomes read-only). Use instead of wrapping a disabled TextArea in Tooltip.',
|
|
716
716
|
isLoading: 'Loading state w/ spinner inside input.',
|
|
@@ -13,10 +13,27 @@ import {useState} from 'react';
|
|
|
13
13
|
import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest';
|
|
14
14
|
import {render, screen, fireEvent, waitFor} from '@testing-library/react';
|
|
15
15
|
import userEvent from '@testing-library/user-event';
|
|
16
|
+
import * as stylex from '@stylexjs/stylex';
|
|
17
|
+
import {colorVars} from '../theme/tokens.stylex';
|
|
16
18
|
import {TestIcon} from '../__tests__/TestIcon';
|
|
17
19
|
import {TextArea} from './TextArea';
|
|
18
20
|
import {__resetLiveRegionsForTest} from '../hooks/useAnnounce';
|
|
19
21
|
|
|
22
|
+
// jsdom cannot match `:focus-visible`: compare the ring's StyleX class hash.
|
|
23
|
+
const RING = stylex
|
|
24
|
+
.props(
|
|
25
|
+
stylex.create({
|
|
26
|
+
ring: {
|
|
27
|
+
boxShadow: {
|
|
28
|
+
default: null,
|
|
29
|
+
':has(> [aria-disabled="true"]:focus-visible)': `inset 0px 0px 0px 2px ${colorVars['--color-accent-muted']}`,
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
}).ring,
|
|
33
|
+
)
|
|
34
|
+
.className!.split(' ')
|
|
35
|
+
.pop()!;
|
|
36
|
+
|
|
20
37
|
// FieldStatus announces status messages through the persistent useAnnounce
|
|
21
38
|
// singletons; remove them between tests so role/aria-live queries in this
|
|
22
39
|
// file never match a leftover region.
|
|
@@ -166,11 +183,50 @@ describe('TextArea', () => {
|
|
|
166
183
|
expect(screen.getByRole('textbox')).toHaveAttribute('rows', '3');
|
|
167
184
|
});
|
|
168
185
|
|
|
169
|
-
it('
|
|
186
|
+
it('exposes aria-disabled and read-only, not native disabled, when isDisabled is true', () => {
|
|
170
187
|
render(
|
|
171
188
|
<TextArea label="Description" isDisabled value="" onChange={() => {}} />,
|
|
172
189
|
);
|
|
173
|
-
|
|
190
|
+
const textarea = screen.getByRole('textbox');
|
|
191
|
+
expect(textarea).not.toBeDisabled();
|
|
192
|
+
expect(textarea).toHaveAttribute('aria-disabled', 'true');
|
|
193
|
+
expect(textarea).toHaveAttribute('readonly');
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('keeps a disabled textarea in the tab order (spec:AST-085)', async () => {
|
|
197
|
+
const user = userEvent.setup();
|
|
198
|
+
render(
|
|
199
|
+
<>
|
|
200
|
+
<TextArea label="First" value="" onChange={() => {}} />
|
|
201
|
+
<TextArea label="Second" isDisabled value="" onChange={() => {}} />
|
|
202
|
+
<TextArea label="Third" value="" onChange={() => {}} />
|
|
203
|
+
</>,
|
|
204
|
+
);
|
|
205
|
+
await user.tab();
|
|
206
|
+
await user.tab();
|
|
207
|
+
expect(screen.getByRole('textbox', {name: 'Second'})).toHaveFocus();
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('runs no caller handler from a disabled textarea and rings it under focus (spec:AST-085 FR2, FR6)', async () => {
|
|
211
|
+
const user = userEvent.setup();
|
|
212
|
+
const handlers = {onPaste: vi.fn(), onKeyDown: vi.fn()};
|
|
213
|
+
render(
|
|
214
|
+
<TextArea
|
|
215
|
+
label="Notes"
|
|
216
|
+
isDisabled
|
|
217
|
+
value=""
|
|
218
|
+
onChange={() => {}}
|
|
219
|
+
{...handlers}
|
|
220
|
+
/>,
|
|
221
|
+
);
|
|
222
|
+
const textarea = screen.getByRole('textbox');
|
|
223
|
+
await user.click(textarea);
|
|
224
|
+
await user.keyboard('a');
|
|
225
|
+
await user.paste('x');
|
|
226
|
+
for (const handler of Object.values(handlers)) {
|
|
227
|
+
expect(handler).not.toHaveBeenCalled();
|
|
228
|
+
}
|
|
229
|
+
expect(textarea.parentElement).toHaveClass(RING);
|
|
174
230
|
});
|
|
175
231
|
|
|
176
232
|
it('is not disabled by default', () => {
|
|
@@ -872,7 +928,10 @@ describe('TextArea', () => {
|
|
|
872
928
|
/>
|
|
873
929
|
</form>,
|
|
874
930
|
);
|
|
875
|
-
expect(screen.getByRole('textbox')).
|
|
931
|
+
expect(screen.getByRole('textbox')).toHaveAttribute(
|
|
932
|
+
'aria-disabled',
|
|
933
|
+
'true',
|
|
934
|
+
);
|
|
876
935
|
expect([
|
|
877
936
|
...new FormData(container.querySelector('form')!).keys(),
|
|
878
937
|
]).toEqual([]);
|
|
@@ -1049,13 +1108,13 @@ describe('TextArea', () => {
|
|
|
1049
1108
|
expect(textarea).toHaveValue('');
|
|
1050
1109
|
});
|
|
1051
1110
|
|
|
1052
|
-
it('
|
|
1111
|
+
it('stays focusable when disabled without a reason', () => {
|
|
1053
1112
|
render(
|
|
1054
1113
|
<TextArea label="Notes" value="" onChange={() => {}} isDisabled />,
|
|
1055
1114
|
);
|
|
1056
1115
|
const textarea = screen.getByRole('textbox');
|
|
1057
|
-
expect(textarea).toBeDisabled();
|
|
1058
|
-
expect(textarea).
|
|
1116
|
+
expect(textarea).not.toBeDisabled();
|
|
1117
|
+
expect(textarea).toHaveAttribute('aria-disabled', 'true');
|
|
1059
1118
|
});
|
|
1060
1119
|
});
|
|
1061
1120
|
});
|
|
@@ -52,6 +52,7 @@ import {
|
|
|
52
52
|
type FieldInputBaseProps,
|
|
53
53
|
useFieldInputStyling,
|
|
54
54
|
} from '../Field/fieldParts';
|
|
55
|
+
import {inputDisabledFocusStyles} from '../Field/inputDisabledFocus';
|
|
55
56
|
import type {SizeValue} from '../utils/types';
|
|
56
57
|
import {useInputContainer} from '../hooks/useInputContainer';
|
|
57
58
|
import {useInputStatusIcon} from '../hooks/useInputStatusIcon';
|
|
@@ -253,7 +254,9 @@ export interface TextAreaProps extends Omit<
|
|
|
253
254
|
*/
|
|
254
255
|
rows?: number;
|
|
255
256
|
/**
|
|
256
|
-
* Whether the textarea is disabled.
|
|
257
|
+
* Whether the textarea is disabled. A disabled textarea is dimmed, cannot be
|
|
258
|
+
* edited, and is left out of form submission, but it stays in the tab order
|
|
259
|
+
* (`aria-disabled`, not the native attribute).
|
|
257
260
|
* @default false
|
|
258
261
|
*/
|
|
259
262
|
isDisabled?: boolean;
|
|
@@ -261,7 +264,7 @@ export interface TextAreaProps extends Omit<
|
|
|
261
264
|
* Whether the textarea is read-only.
|
|
262
265
|
* The value is shown at full opacity and still submits with the form, but
|
|
263
266
|
* cannot be edited. Unlike `isDisabled`, a read-only textarea is not dimmed
|
|
264
|
-
* and
|
|
267
|
+
* and keeps its form value — use it for a value the user should see and
|
|
265
268
|
* send but not change. `isDisabled` takes precedence when both are set.
|
|
266
269
|
* @default false
|
|
267
270
|
*/
|
|
@@ -273,8 +276,8 @@ export interface TextAreaProps extends Omit<
|
|
|
273
276
|
* discoverable by keyboard and assistive technology. The field cannot be
|
|
274
277
|
* edited (it becomes read-only) while disabled.
|
|
275
278
|
*
|
|
276
|
-
* Use this instead of wrapping a disabled textarea in `Tooltip
|
|
277
|
-
*
|
|
279
|
+
* Use this instead of wrapping a disabled textarea in `Tooltip`, so the
|
|
280
|
+
* reason is attached to the field itself.
|
|
278
281
|
*
|
|
279
282
|
* @example
|
|
280
283
|
* ```
|
|
@@ -443,12 +446,20 @@ export function TextArea({
|
|
|
443
446
|
// (spec:AST-086 FR8).
|
|
444
447
|
useBusyAnnouncement(isBusy, t('@astryx.spinner.loading'), containerRef);
|
|
445
448
|
|
|
446
|
-
// Disabled-reason tooltip.
|
|
447
|
-
// tooltip listeners attach to the textarea container (which already exists)
|
|
449
|
+
// Disabled-reason tooltip. The tooltip listeners attach to the textarea container (which already exists)
|
|
448
450
|
// and the textarea stays perceivable via aria-disabled instead of the native
|
|
449
451
|
// disabled attribute. The field is made read-only so it can't be typed into,
|
|
450
452
|
// and value mutation is blocked by the isDisabled guard in handleChange.
|
|
451
453
|
const showsDisabledMessage = isDisabled && !!disabledMessage;
|
|
454
|
+
// A disabled field keeps its tab stop but, like a natively disabled one,
|
|
455
|
+
// runs no caller event handler other than focus and blur (`spec:AST-085` FR2).
|
|
456
|
+
const fieldProps = isDisabled
|
|
457
|
+
? Object.fromEntries(
|
|
458
|
+
Object.entries(rest).filter(
|
|
459
|
+
([key]) => !/^on[A-Z]/.test(key) || /^on(Focus|Blur)/.test(key),
|
|
460
|
+
),
|
|
461
|
+
)
|
|
462
|
+
: rest;
|
|
452
463
|
const disabledMessageTooltip = useTooltip({
|
|
453
464
|
placement: 'above',
|
|
454
465
|
// The container div is not naturally focusable; focusin bubbles up from
|
|
@@ -598,6 +609,7 @@ export function TextArea({
|
|
|
598
609
|
inputWrapperStyles.base,
|
|
599
610
|
styles.wrapper,
|
|
600
611
|
isDisabled && inputWrapperStyles.disabled,
|
|
612
|
+
isDisabled && inputDisabledFocusStyles.ring,
|
|
601
613
|
status && inputStatusBorderStyles[status.type],
|
|
602
614
|
status && !isDisabled && inputStatusHoverShadowStyles[status.type],
|
|
603
615
|
status && inputStatusFocusWithinStyles[status.type],
|
|
@@ -612,23 +624,23 @@ export function TextArea({
|
|
|
612
624
|
</span>
|
|
613
625
|
)}
|
|
614
626
|
<textarea
|
|
615
|
-
{...
|
|
627
|
+
{...fieldProps}
|
|
616
628
|
ref={useMergedRefs(ref, textareaRef)}
|
|
617
629
|
id={id}
|
|
618
630
|
name={isDisabled ? undefined : htmlName}
|
|
619
631
|
value={optimisticValue}
|
|
620
632
|
onChange={handleChange}
|
|
621
|
-
onPaste={onPaste}
|
|
633
|
+
onPaste={isDisabled ? undefined : onPaste}
|
|
622
634
|
onFocus={onFocus}
|
|
623
635
|
onBlur={onBlur}
|
|
624
636
|
placeholder={placeholder}
|
|
625
637
|
rows={rows}
|
|
626
|
-
//
|
|
627
|
-
// aria-disabled
|
|
628
|
-
// handleChange guard
|
|
629
|
-
|
|
630
|
-
aria-disabled={
|
|
631
|
-
readOnly={isReadOnly ||
|
|
638
|
+
// A disabled textarea keeps its place in the tab order
|
|
639
|
+
// (spec:AST-085): aria-disabled instead of the native attribute,
|
|
640
|
+
// with readOnly + the handleChange guard keeping the value from
|
|
641
|
+
// changing.
|
|
642
|
+
aria-disabled={isDisabled ? 'true' : undefined}
|
|
643
|
+
readOnly={isReadOnly || isDisabled || undefined}
|
|
632
644
|
spellCheck={hasSpellCheck}
|
|
633
645
|
autoFocus={hasAutoFocus}
|
|
634
646
|
data-autofocus={hasAutoFocus || undefined}
|
|
@@ -93,14 +93,14 @@ export const docs = {
|
|
|
93
93
|
name: 'isReadOnly',
|
|
94
94
|
type: 'boolean',
|
|
95
95
|
description:
|
|
96
|
-
'Makes the input read-only: the value is shown at full opacity and still submits with the form, but cannot be edited. Unlike isDisabled, a read-only input is not dimmed and
|
|
96
|
+
'Makes the input read-only: the value is shown at full opacity and still submits with the form, but cannot be edited. Unlike isDisabled, a read-only input is not dimmed and keeps its form value. isDisabled takes precedence when both are set.',
|
|
97
97
|
default: 'false',
|
|
98
98
|
},
|
|
99
99
|
{
|
|
100
100
|
name: 'disabledMessage',
|
|
101
101
|
type: 'string',
|
|
102
102
|
description:
|
|
103
|
-
'Explains why the input is disabled. With isDisabled, shows a tooltip on hover/keyboard focus and keeps the input focusable via aria-disabled (the field becomes read-only). Use this instead of wrapping a disabled TextInput in Tooltip
|
|
103
|
+
'Explains why the input is disabled. With isDisabled, shows a tooltip on hover/keyboard focus and keeps the input focusable via aria-disabled (the field becomes read-only). Use this instead of wrapping a disabled TextInput in Tooltip, so the reason is attached to the field itself.',
|
|
104
104
|
},
|
|
105
105
|
{
|
|
106
106
|
name: 'isLoading',
|
|
@@ -219,7 +219,7 @@ export const docs = {
|
|
|
219
219
|
{guidance: false, description: "Don't use placeholder text as a replacement for a label; placeholders disappear on focus and are not reliably read by screen readers."},
|
|
220
220
|
{guidance: false, description: "Don't use TextInput for multi-line content like comments or descriptions; use TextArea instead."},
|
|
221
221
|
{guidance: false, description: "Don't mark every field as required; only flag mandatory fields so users are not overwhelmed by validation errors."},
|
|
222
|
-
{guidance: false, description: "Don't wrap a disabled TextInput in Tooltip to explain why it's disabled
|
|
222
|
+
{guidance: false, description: "Don't wrap a disabled TextInput in Tooltip to explain why it's disabled. Use the disabledMessage prop, which attaches the reason to the field itself."},
|
|
223
223
|
],
|
|
224
224
|
anatomy: [
|
|
225
225
|
{name: 'Label', required: true, description: 'Text that identifies the field. Always rendered for accessibility even when visually hidden.'},
|
|
@@ -311,14 +311,14 @@ export const docsZh = {
|
|
|
311
311
|
name: 'isReadOnly',
|
|
312
312
|
type: 'boolean',
|
|
313
313
|
description:
|
|
314
|
-
'将输入框设为只读:值以完整不透明度显示并仍随表单提交,但无法编辑。与 isDisabled
|
|
314
|
+
'将输入框设为只读:值以完整不透明度显示并仍随表单提交,但无法编辑。与 isDisabled 不同,只读输入框不会变暗,并保留其表单值。同时设置时 isDisabled 优先。',
|
|
315
315
|
default: 'false',
|
|
316
316
|
},
|
|
317
317
|
{
|
|
318
318
|
name: 'disabledMessage',
|
|
319
319
|
type: 'string',
|
|
320
320
|
description:
|
|
321
|
-
'说明输入框被禁用的原因。与 isDisabled 一起使用时,在悬停/键盘聚焦时显示工具提示,并通过 aria-disabled 保持输入框可聚焦(字段变为只读)。请使用此属性,而不是用 Tooltip 包裹已禁用的 TextInput
|
|
321
|
+
'说明输入框被禁用的原因。与 isDisabled 一起使用时,在悬停/键盘聚焦时显示工具提示,并通过 aria-disabled 保持输入框可聚焦(字段变为只读)。请使用此属性,而不是用 Tooltip 包裹已禁用的 TextInput,以便把原因附加在字段本身上。',
|
|
322
322
|
},
|
|
323
323
|
{
|
|
324
324
|
name: 'isLoading',
|
|
@@ -430,7 +430,7 @@ export const docsZh = {
|
|
|
430
430
|
{guidance: false, description: "Don't use placeholder text as a replacement for a label; placeholders disappear on focus and are not reliably read by screen readers."},
|
|
431
431
|
{guidance: false, description: "Don't use TextInput for multi-line content like comments or descriptions; use TextArea instead."},
|
|
432
432
|
{guidance: false, description: "Don't mark every field as required; only flag mandatory fields so users are not overwhelmed by validation errors."},
|
|
433
|
-
{guidance: false, description: "Don't wrap a disabled TextInput in Tooltip to explain why it's disabled
|
|
433
|
+
{guidance: false, description: "Don't wrap a disabled TextInput in Tooltip to explain why it's disabled. Use the disabledMessage prop, which attaches the reason to the field itself."},
|
|
434
434
|
],
|
|
435
435
|
anatomy: [
|
|
436
436
|
{name: 'Label', required: true, description: 'Text that identifies the field. Always rendered for accessibility even when visually hidden.'},
|
|
@@ -458,7 +458,7 @@ export const docsDense = {
|
|
|
458
458
|
{guidance: false, description: "Don't use placeholder text as a replacement for a label; placeholders disappear on focus and are not reliably read by screen readers."},
|
|
459
459
|
{guidance: false, description: "Don't use TextInput for multi-line content like comments or descriptions; use TextArea instead."},
|
|
460
460
|
{guidance: false, description: "Don't mark every field as required; only flag mandatory fields so users are not overwhelmed by validation errors."},
|
|
461
|
-
{guidance: false, description: "Don't wrap a disabled TextInput in Tooltip to explain why it's disabled
|
|
461
|
+
{guidance: false, description: "Don't wrap a disabled TextInput in Tooltip to explain why it's disabled. Use the disabledMessage prop, which attaches the reason to the field itself."},
|
|
462
462
|
],
|
|
463
463
|
anatomy: [
|
|
464
464
|
{name: 'Label', required: true, description: 'Text that identifies the field. Always rendered for accessibility even when visually hidden.'},
|
|
@@ -489,7 +489,7 @@ export const docsDense = {
|
|
|
489
489
|
isRequired: 'Shows "Required" indicator+sets aria-required. Mutually exclusive w/ isOptional.',
|
|
490
490
|
isDisabled: 'Disables input, prevents interaction, dims element.',
|
|
491
491
|
isReadOnly:
|
|
492
|
-
'Read-only: value visible + still submits, but not editable. Unlike isDisabled: not dimmed
|
|
492
|
+
'Read-only: value visible + still submits, but not editable. Unlike isDisabled: not dimmed.',
|
|
493
493
|
disabledMessage:
|
|
494
494
|
'Explains why input is disabled. With isDisabled, shows tooltip on hover/focus + keeps input focusable via aria-disabled (field becomes read-only). Use instead of wrapping a disabled TextInput in Tooltip.',
|
|
495
495
|
isLoading: 'Loading state w/ spinner+aria-busy.',
|
|
@@ -13,11 +13,28 @@
|
|
|
13
13
|
import {describe, it, expect, vi, beforeEach} from 'vitest';
|
|
14
14
|
import {render, screen, fireEvent, waitFor} from '@testing-library/react';
|
|
15
15
|
import userEvent from '@testing-library/user-event';
|
|
16
|
+
import * as stylex from '@stylexjs/stylex';
|
|
17
|
+
import {colorVars} from '../theme/tokens.stylex';
|
|
16
18
|
import {TestIcon} from '../__tests__/TestIcon';
|
|
17
19
|
import {declaredValue} from '../__tests__/stylexDeclarations';
|
|
18
20
|
import {InputGroup} from '../InputGroup';
|
|
19
21
|
import {TextInput} from './TextInput';
|
|
20
22
|
|
|
23
|
+
// jsdom cannot match `:focus-visible`: compare the ring's StyleX class hash.
|
|
24
|
+
const RING = stylex
|
|
25
|
+
.props(
|
|
26
|
+
stylex.create({
|
|
27
|
+
ring: {
|
|
28
|
+
boxShadow: {
|
|
29
|
+
default: null,
|
|
30
|
+
':has(> [aria-disabled="true"]:focus-visible)': `inset 0px 0px 0px 2px ${colorVars['--color-accent-muted']}`,
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
}).ring,
|
|
34
|
+
)
|
|
35
|
+
.className!.split(' ')
|
|
36
|
+
.pop()!;
|
|
37
|
+
|
|
21
38
|
// Mock showPopover/hidePopover since jsdom does not implement them. Used by the
|
|
22
39
|
// disabledMessage tooltip.
|
|
23
40
|
beforeEach(() => {
|
|
@@ -138,9 +155,55 @@ describe('TextInput', () => {
|
|
|
138
155
|
expect(screen.getByRole('textbox')).not.toHaveAttribute('aria-required');
|
|
139
156
|
});
|
|
140
157
|
|
|
141
|
-
it('
|
|
158
|
+
it('exposes aria-disabled and read-only, not native disabled, when isDisabled is true', () => {
|
|
159
|
+
render(<TextInput label="Name" isDisabled value="" onChange={() => {}} />);
|
|
160
|
+
const input = screen.getByRole('textbox');
|
|
161
|
+
expect(input).not.toBeDisabled();
|
|
162
|
+
expect(input).toHaveAttribute('aria-disabled', 'true');
|
|
163
|
+
expect(input).toHaveAttribute('readonly');
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('keeps a disabled input in the tab order (spec:AST-085)', async () => {
|
|
167
|
+
const user = userEvent.setup();
|
|
168
|
+
render(
|
|
169
|
+
<>
|
|
170
|
+
<TextInput label="First" value="" onChange={() => {}} />
|
|
171
|
+
<TextInput label="Second" isDisabled value="" onChange={() => {}} />
|
|
172
|
+
<TextInput label="Third" value="" onChange={() => {}} />
|
|
173
|
+
</>,
|
|
174
|
+
);
|
|
175
|
+
await user.tab();
|
|
176
|
+
await user.tab();
|
|
177
|
+
expect(screen.getByRole('textbox', {name: 'Second'})).toHaveFocus();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('runs no caller handler and submits no form from a disabled input (spec:AST-085 FR2)', async () => {
|
|
181
|
+
const user = userEvent.setup();
|
|
182
|
+
const handlers = {onEnter: vi.fn(), onKeyDown: vi.fn(), onPaste: vi.fn()};
|
|
183
|
+
const onSubmit = vi.fn((e: React.FormEvent) => e.preventDefault());
|
|
184
|
+
render(
|
|
185
|
+
<form onSubmit={onSubmit}>
|
|
186
|
+
<TextInput
|
|
187
|
+
label="Name"
|
|
188
|
+
isDisabled
|
|
189
|
+
value=""
|
|
190
|
+
onChange={() => {}}
|
|
191
|
+
{...handlers}
|
|
192
|
+
/>
|
|
193
|
+
</form>,
|
|
194
|
+
);
|
|
195
|
+
await user.click(screen.getByRole('textbox'));
|
|
196
|
+
await user.keyboard('{Enter}');
|
|
197
|
+
await user.paste('x');
|
|
198
|
+
for (const handler of [...Object.values(handlers), onSubmit]) {
|
|
199
|
+
expect(handler).not.toHaveBeenCalled();
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('rings a disabled input under keyboard focus (spec:AST-085 FR6)', () => {
|
|
142
204
|
render(<TextInput label="Name" isDisabled value="" onChange={() => {}} />);
|
|
143
|
-
|
|
205
|
+
const wrapper = screen.getByRole('textbox').parentElement!;
|
|
206
|
+
expect(wrapper).toHaveClass(RING);
|
|
144
207
|
});
|
|
145
208
|
|
|
146
209
|
it('does not fire onChange when disabled', async () => {
|
|
@@ -515,7 +578,10 @@ describe('TextInput', () => {
|
|
|
515
578
|
/>
|
|
516
579
|
</form>,
|
|
517
580
|
);
|
|
518
|
-
expect(screen.getByRole('textbox')).
|
|
581
|
+
expect(screen.getByRole('textbox')).toHaveAttribute(
|
|
582
|
+
'aria-disabled',
|
|
583
|
+
'true',
|
|
584
|
+
);
|
|
519
585
|
expect([
|
|
520
586
|
...new FormData(container.querySelector('form')!).keys(),
|
|
521
587
|
]).toEqual([]);
|
|
@@ -888,13 +954,13 @@ describe('TextInput', () => {
|
|
|
888
954
|
expect(input).toHaveValue('');
|
|
889
955
|
});
|
|
890
956
|
|
|
891
|
-
it('
|
|
957
|
+
it('stays focusable when disabled without a reason', () => {
|
|
892
958
|
render(
|
|
893
959
|
<TextInput label="Owner" value="" onChange={() => {}} isDisabled />,
|
|
894
960
|
);
|
|
895
961
|
const input = screen.getByRole('textbox');
|
|
896
|
-
expect(input).toBeDisabled();
|
|
897
|
-
expect(input).
|
|
962
|
+
expect(input).not.toBeDisabled();
|
|
963
|
+
expect(input).toHaveAttribute('aria-disabled', 'true');
|
|
898
964
|
});
|
|
899
965
|
});
|
|
900
966
|
});
|
|
@@ -113,6 +113,7 @@ import {
|
|
|
113
113
|
type FieldInputBaseProps,
|
|
114
114
|
useFieldInputStyling,
|
|
115
115
|
} from '../Field/fieldParts';
|
|
116
|
+
import {inputDisabledFocusStyles} from '../Field/inputDisabledFocus';
|
|
116
117
|
import type {SizeValue} from '../utils/types';
|
|
117
118
|
import {themeProps} from '../utils/themeProps';
|
|
118
119
|
import {useTranslator} from '../i18n';
|
|
@@ -156,7 +157,9 @@ export interface TextInputProps extends Omit<
|
|
|
156
157
|
*/
|
|
157
158
|
isRequired?: boolean;
|
|
158
159
|
/**
|
|
159
|
-
* Whether the input is disabled.
|
|
160
|
+
* Whether the input is disabled. A disabled input is dimmed, cannot be
|
|
161
|
+
* edited, and is left out of form submission, but it stays in the tab order
|
|
162
|
+
* (`aria-disabled`, not the native attribute).
|
|
160
163
|
* @default false
|
|
161
164
|
*/
|
|
162
165
|
isDisabled?: boolean;
|
|
@@ -164,7 +167,7 @@ export interface TextInputProps extends Omit<
|
|
|
164
167
|
* Whether the input is read-only.
|
|
165
168
|
* The value is shown at full opacity and still submits with the form, but
|
|
166
169
|
* cannot be edited. Unlike `isDisabled`, a read-only input is not dimmed and
|
|
167
|
-
*
|
|
170
|
+
* keeps its form value — use it for a value the user should see and send
|
|
168
171
|
* but not change. `isDisabled` takes precedence when both are set.
|
|
169
172
|
* @default false
|
|
170
173
|
*/
|
|
@@ -176,8 +179,8 @@ export interface TextInputProps extends Omit<
|
|
|
176
179
|
* keyboard and assistive technology. The field cannot be edited (it becomes
|
|
177
180
|
* read-only) while disabled.
|
|
178
181
|
*
|
|
179
|
-
* Use this instead of wrapping a disabled input in `Tooltip
|
|
180
|
-
*
|
|
182
|
+
* Use this instead of wrapping a disabled input in `Tooltip`, so the reason
|
|
183
|
+
* is attached to the field itself.
|
|
181
184
|
*
|
|
182
185
|
* @example
|
|
183
186
|
* ```
|
|
@@ -331,6 +334,15 @@ export function TextInput({
|
|
|
331
334
|
});
|
|
332
335
|
const t = useTranslator();
|
|
333
336
|
const isEffectivelyRequired = useResolvedRequired({isRequired, isOptional});
|
|
337
|
+
// A disabled field keeps its tab stop but, like a natively disabled one,
|
|
338
|
+
// runs no caller event handler other than focus and blur (`spec:AST-085` FR2).
|
|
339
|
+
const fieldProps = isDisabled
|
|
340
|
+
? Object.fromEntries(
|
|
341
|
+
Object.entries(rest).filter(
|
|
342
|
+
([key]) => !/^on[A-Z]/.test(key) || /^on(Focus|Blur)/.test(key),
|
|
343
|
+
),
|
|
344
|
+
)
|
|
345
|
+
: rest;
|
|
334
346
|
const size = useSize(sizeProp, 'md');
|
|
335
347
|
|
|
336
348
|
const generatedID = useId();
|
|
@@ -349,8 +361,7 @@ export function TextInput({
|
|
|
349
361
|
// (spec:AST-086 FR8).
|
|
350
362
|
useBusyAnnouncement(isBusy, t('@astryx.spinner.loading'), containerRef);
|
|
351
363
|
|
|
352
|
-
// Disabled-reason tooltip.
|
|
353
|
-
// tooltip listeners attach to the input container (which already exists) and
|
|
364
|
+
// Disabled-reason tooltip. The tooltip listeners attach to the input container (which already exists) and
|
|
354
365
|
// the input stays perceivable via aria-disabled instead of the native
|
|
355
366
|
// disabled attribute. The field is made read-only so it can't be typed into,
|
|
356
367
|
// and value mutation is blocked by the isDisabled guard in handleChange.
|
|
@@ -451,6 +462,7 @@ export function TextInput({
|
|
|
451
462
|
inputWrapperStyles.base,
|
|
452
463
|
sizeStyles[size],
|
|
453
464
|
isDisabled && inputWrapperStyles.disabled,
|
|
465
|
+
isDisabled && inputDisabledFocusStyles.ring,
|
|
454
466
|
status && inputStatusBorderStyles[status.type],
|
|
455
467
|
status && !isDisabled && inputStatusHoverShadowStyles[status.type],
|
|
456
468
|
status && inputStatusFocusWithinStyles[status.type],
|
|
@@ -463,7 +475,7 @@ export function TextInput({
|
|
|
463
475
|
{startIcon && renderIconSlot(startIcon, {size: 'sm', color: 'secondary'})}
|
|
464
476
|
{inputGroup && <VisuallyHidden id={inputLabelID}>{label}</VisuallyHidden>}
|
|
465
477
|
<input
|
|
466
|
-
{...
|
|
478
|
+
{...fieldProps}
|
|
467
479
|
ref={useMergedRefs(ref, inputRef)}
|
|
468
480
|
id={id}
|
|
469
481
|
name={isDisabled ? undefined : htmlName}
|
|
@@ -471,8 +483,15 @@ export function TextInput({
|
|
|
471
483
|
value={optimisticValue}
|
|
472
484
|
onChange={handleChange}
|
|
473
485
|
onKeyDown={
|
|
474
|
-
onEnter || onKeyDown
|
|
486
|
+
isDisabled || onEnter || onKeyDown
|
|
475
487
|
? e => {
|
|
488
|
+
if (isDisabled) {
|
|
489
|
+
// A read-only field still submits its form on Enter.
|
|
490
|
+
if (e.key === 'Enter') {
|
|
491
|
+
e.preventDefault();
|
|
492
|
+
}
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
476
495
|
// The composing keydown fires before compositionend, so without
|
|
477
496
|
// this guard pressing Enter to commit a Japanese/Chinese/Korean
|
|
478
497
|
// IME conversion would trigger onEnter (submit/save actions)
|
|
@@ -486,12 +505,11 @@ export function TextInput({
|
|
|
486
505
|
: undefined
|
|
487
506
|
}
|
|
488
507
|
placeholder={placeholder}
|
|
489
|
-
//
|
|
490
|
-
//
|
|
491
|
-
//
|
|
492
|
-
disabled={isDisabled
|
|
493
|
-
|
|
494
|
-
readOnly={isReadOnly || showsDisabledMessage || undefined}
|
|
508
|
+
// A disabled input keeps its place in the tab order (spec:AST-085):
|
|
509
|
+
// aria-disabled instead of the native attribute, with readOnly + the
|
|
510
|
+
// handleChange guard keeping the value from changing.
|
|
511
|
+
aria-disabled={isDisabled ? 'true' : undefined}
|
|
512
|
+
readOnly={isReadOnly || isDisabled || undefined}
|
|
495
513
|
autoFocus={hasAutoFocus}
|
|
496
514
|
data-autofocus={hasAutoFocus || undefined}
|
|
497
515
|
aria-describedby={
|