@spark-web/field 1.0.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/CHANGELOG.md +21 -0
- package/README.md +143 -0
- package/dist/declarations/src/Field.d.ts +49 -0
- package/dist/declarations/src/context.d.ts +11 -0
- package/dist/declarations/src/index.d.ts +4 -0
- package/dist/spark-web-field.cjs.d.ts +1 -0
- package/dist/spark-web-field.cjs.dev.js +190 -0
- package/dist/spark-web-field.cjs.js +7 -0
- package/dist/spark-web-field.cjs.prod.js +190 -0
- package/dist/spark-web-field.esm.js +179 -0
- package/package.json +25 -0
- package/src/Field.tsx +214 -0
- package/src/context.ts +24 -0
- package/src/index.ts +7 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# @spark-web/field
|
|
2
|
+
|
|
3
|
+
## 1.0.0
|
|
4
|
+
|
|
5
|
+
### Major Changes
|
|
6
|
+
|
|
7
|
+
- [#27](https://github.com/brighte-labs/spark-web/pull/27)
|
|
8
|
+
[`4c8e398`](https://github.com/brighte-labs/spark-web/commit/4c8e3988f8a59d3dab60a6b67b1128b6ff2a5f2c)
|
|
9
|
+
Thanks [@JedWatson](https://github.com/JedWatson)! - Initial Version
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- Updated dependencies
|
|
14
|
+
[[`4c8e398`](https://github.com/brighte-labs/spark-web/commit/4c8e3988f8a59d3dab60a6b67b1128b6ff2a5f2c)]:
|
|
15
|
+
- @spark-web/a11y@1.0.0
|
|
16
|
+
- @spark-web/box@1.0.0
|
|
17
|
+
- @spark-web/icon@1.0.0
|
|
18
|
+
- @spark-web/stack@1.0.0
|
|
19
|
+
- @spark-web/text@1.0.0
|
|
20
|
+
- @spark-web/theme@1.0.0
|
|
21
|
+
- @spark-web/utils@1.0.0
|
package/README.md
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Field
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
Using context, the field component connects the label, description, and message
|
|
6
|
+
to the input element.
|
|
7
|
+
|
|
8
|
+
```jsx live
|
|
9
|
+
<Field label="Label">
|
|
10
|
+
<TextInput />
|
|
11
|
+
</Field>
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Example
|
|
15
|
+
|
|
16
|
+
### Label
|
|
17
|
+
|
|
18
|
+
Each field must be accompanied by a label. Effective form labeling helps users
|
|
19
|
+
understand what information to enter into an input.
|
|
20
|
+
|
|
21
|
+
Using placeholder text in lieu of a label is sometimes employed as a
|
|
22
|
+
space-saving method. However, this is not recommended because it hides context
|
|
23
|
+
and presents accessibility issues.
|
|
24
|
+
|
|
25
|
+
```jsx live
|
|
26
|
+
<Field label="Name">
|
|
27
|
+
<TextInput />
|
|
28
|
+
</Field>
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
#### Label visibility
|
|
32
|
+
|
|
33
|
+
The label must always be provided for assistive technology, but you may hide it
|
|
34
|
+
from sighted users when the intent can be inferred from context.
|
|
35
|
+
|
|
36
|
+
```jsx live
|
|
37
|
+
<Stack gap="xlarge">
|
|
38
|
+
<Field label="Name" labelVisibility="hidden">
|
|
39
|
+
<TextInput placeholder="hidden" />
|
|
40
|
+
</Field>
|
|
41
|
+
<Columns gap="small">
|
|
42
|
+
<Field label="Name">
|
|
43
|
+
<TextInput placeholder="visible" />
|
|
44
|
+
</Field>
|
|
45
|
+
<Field label="Name" labelVisibility="reserve-space">
|
|
46
|
+
<TextInput placeholder="reserve-space" />
|
|
47
|
+
</Field>
|
|
48
|
+
</Columns>
|
|
49
|
+
</Stack>
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
#### Secondary label
|
|
53
|
+
|
|
54
|
+
Provide additional context, typically used to indicate that the field is
|
|
55
|
+
optional.
|
|
56
|
+
|
|
57
|
+
```jsx live
|
|
58
|
+
<Field label="Name" secondaryLabel="(Optional)">
|
|
59
|
+
<TextInput />
|
|
60
|
+
</Field>
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### Adornment
|
|
64
|
+
|
|
65
|
+
Optionally provide a utility or contextual hint, related to the field.
|
|
66
|
+
|
|
67
|
+
```jsx live
|
|
68
|
+
<Field
|
|
69
|
+
label="Username"
|
|
70
|
+
adornment={
|
|
71
|
+
<Text>
|
|
72
|
+
<TextLink href="#">Forgot username?</TextLink>
|
|
73
|
+
</Text>
|
|
74
|
+
}
|
|
75
|
+
>
|
|
76
|
+
<TextInput />
|
|
77
|
+
</Field>
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### Description
|
|
81
|
+
|
|
82
|
+
Provides pertinent information that assists the user in completing a field.
|
|
83
|
+
Description text is always visible and appears underneath the label. Use
|
|
84
|
+
sentence-style capitalisation, and in most cases, write the text as full
|
|
85
|
+
sentences with punctuation.
|
|
86
|
+
|
|
87
|
+
```jsx live
|
|
88
|
+
<Field
|
|
89
|
+
label="Email"
|
|
90
|
+
description="We take your privacy seriously. We will never give your email to a third party."
|
|
91
|
+
>
|
|
92
|
+
<TextInput type="email" />
|
|
93
|
+
</Field>
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### Message and tone
|
|
97
|
+
|
|
98
|
+
The “message” is used to communicate the status of a field, such as an error
|
|
99
|
+
message. This will be announced on focus and can be combined with a “tone” to
|
|
100
|
+
illustrate intent.
|
|
101
|
+
|
|
102
|
+
```jsx live
|
|
103
|
+
<Stack gap="xlarge">
|
|
104
|
+
<Field label="Label" tone="critical" message="Critical message">
|
|
105
|
+
<TextInput />
|
|
106
|
+
</Field>
|
|
107
|
+
<Field label="Label" tone="positive" message="Positive message">
|
|
108
|
+
<TextInput />
|
|
109
|
+
</Field>
|
|
110
|
+
<Field label="Label" tone="neutral" message="Neutral message">
|
|
111
|
+
<TextInput />
|
|
112
|
+
</Field>
|
|
113
|
+
</Stack>
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### Disabled
|
|
117
|
+
|
|
118
|
+
Mark the field as disabled by passing true to the disabled prop.
|
|
119
|
+
|
|
120
|
+
```jsx live
|
|
121
|
+
<Field label="Label" secondaryLabel="Secondary label" disabled>
|
|
122
|
+
<TextInput value="Text in disabled field" />
|
|
123
|
+
</Field>
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## Props
|
|
127
|
+
|
|
128
|
+
| Prop | Type | Default | Description |
|
|
129
|
+
| ---------------- | ---------------------------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
130
|
+
| id? | string | | Sets a unique identifier for the component. |
|
|
131
|
+
| data? | [DataAttributeMap][data-attribute-map] | | Sets data attributes on the component. |
|
|
132
|
+
| adornment? | React.ReactElement | | Optionally provide a utility or contextual hint, related to the field. |
|
|
133
|
+
| children | React.ReactNode | | Children elements to be rendered within the component. Expected to be Input elements. |
|
|
134
|
+
| disabled? | boolean | | Indicates that the field is perceivable but disabled, so it is not editable or otherwise operable. |
|
|
135
|
+
| description? | string | | Sets a description for the field to provide additional information that will aid user input. |
|
|
136
|
+
| label | string | | Sets a label for the field. |
|
|
137
|
+
| labelVisibility? | 'hidden' \| 'reserve-space' \| 'visible' | 'visible' | The label must always be provided for assistive technology, but you may hide it from sighted users when the intent can be inferred from context. |
|
|
138
|
+
| message? | string | | Provide a message, informing the user about changes in state. |
|
|
139
|
+
| secondaryLabel? | string | | Provides additional context, typically used to indicate that the field is optional. |
|
|
140
|
+
| tone? | 'critical' \| 'neutral' \| 'positive' | 'neutral' | Provide a tone to influence elements of the field, and its input. |
|
|
141
|
+
|
|
142
|
+
[data-attribute-map]:
|
|
143
|
+
https://bitbucket.org/brighte-energy/energy/src/14a694872cc43bb454981bada65f5f12b56f77c9/spark-web/packages/utils-spark/src/buildDataAttributes.ts#spark-web/packages/utils-spark/src/buildDataAttributes.ts-1
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { DataAttributeMap } from '@spark-web/utils/internal';
|
|
2
|
+
import type { ReactElement, ReactNode } from 'react';
|
|
3
|
+
export declare type Tone = keyof typeof messageToneMap;
|
|
4
|
+
export declare type FieldProps = {
|
|
5
|
+
id?: string;
|
|
6
|
+
data?: DataAttributeMap;
|
|
7
|
+
/** Optionally provide a utility or contextual hint, related to the field. */
|
|
8
|
+
adornment?: ReactElement;
|
|
9
|
+
/** Input component */
|
|
10
|
+
children: ReactNode;
|
|
11
|
+
/**
|
|
12
|
+
* Indicates that the field is perceivable but disabled, so it is not editable
|
|
13
|
+
* or otherwise operable.
|
|
14
|
+
*/
|
|
15
|
+
disabled?: boolean;
|
|
16
|
+
/** Provide additional information that will aid user input. */
|
|
17
|
+
description?: string;
|
|
18
|
+
/** Concisely label the field. */
|
|
19
|
+
label: string;
|
|
20
|
+
/**
|
|
21
|
+
* The label must always be provided for assistive technology, but you may
|
|
22
|
+
* hide it from sighted users when the intent can be inferred from context.
|
|
23
|
+
*/
|
|
24
|
+
labelVisibility?: 'hidden' | 'reserve-space' | 'visible';
|
|
25
|
+
/** Provide a message, informing the user about changes in state. */
|
|
26
|
+
message?: string;
|
|
27
|
+
/** Additional context, typically used to indicate that the field is optional. */
|
|
28
|
+
secondaryLabel?: string;
|
|
29
|
+
/** Provide a tone to influence elements of the field, and its input. */
|
|
30
|
+
tone?: Tone;
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* Using a [context](https://reactjs.org/docs/context.html), the field
|
|
34
|
+
* component connects the label, description, and message to the input element.
|
|
35
|
+
*/
|
|
36
|
+
export declare const Field: import("react").ForwardRefExoticComponent<FieldProps & import("react").RefAttributes<HTMLDivElement>>;
|
|
37
|
+
export declare function useFieldIds(id?: string): {
|
|
38
|
+
descriptionId: string;
|
|
39
|
+
inputId: string;
|
|
40
|
+
messageId: string;
|
|
41
|
+
};
|
|
42
|
+
declare const messageToneMap: {
|
|
43
|
+
readonly critical: "critical";
|
|
44
|
+
readonly neutral: "muted";
|
|
45
|
+
readonly positive: "positive";
|
|
46
|
+
};
|
|
47
|
+
declare type FieldMessageProps = Required<Pick<FieldProps, 'message' | 'id' | 'tone'>>;
|
|
48
|
+
export declare const FieldMessage: ({ message, id, tone }: FieldMessageProps) => JSX.Element;
|
|
49
|
+
export {};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/// <reference types="react" />
|
|
2
|
+
export declare type FieldContextType = {
|
|
3
|
+
'aria-describedby'?: string;
|
|
4
|
+
id: string;
|
|
5
|
+
disabled: boolean;
|
|
6
|
+
invalid: boolean;
|
|
7
|
+
};
|
|
8
|
+
export declare const FieldContext: import("react").Context<FieldContextType | null>;
|
|
9
|
+
export declare const FieldContextProvider: import("react").Provider<FieldContextType | null>;
|
|
10
|
+
export declare const FIELD_CONTEXT_ERROR_MESSAGE = "Input components must be inside a `Field`.";
|
|
11
|
+
export declare function useFieldContext(): FieldContextType;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./declarations/src/index";
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
Object.defineProperty(exports, '__esModule', { value: true });
|
|
4
|
+
|
|
5
|
+
var React = require('react');
|
|
6
|
+
var _objectWithoutProperties = require('@babel/runtime/helpers/esm/objectWithoutProperties');
|
|
7
|
+
var _extends = require('@babel/runtime/helpers/esm/extends');
|
|
8
|
+
var css = require('@emotion/css');
|
|
9
|
+
var a11y = require('@spark-web/a11y');
|
|
10
|
+
var box = require('@spark-web/box');
|
|
11
|
+
var icon = require('@spark-web/icon');
|
|
12
|
+
var stack = require('@spark-web/stack');
|
|
13
|
+
var text = require('@spark-web/text');
|
|
14
|
+
var theme = require('@spark-web/theme');
|
|
15
|
+
var internal = require('@spark-web/utils/internal');
|
|
16
|
+
|
|
17
|
+
function _interopDefault (e) { return e && e.__esModule ? e : { 'default': e }; }
|
|
18
|
+
|
|
19
|
+
var React__default = /*#__PURE__*/_interopDefault(React);
|
|
20
|
+
|
|
21
|
+
var FieldContext = /*#__PURE__*/React.createContext(null);
|
|
22
|
+
var FieldContextProvider = FieldContext.Provider;
|
|
23
|
+
var FIELD_CONTEXT_ERROR_MESSAGE = 'Input components must be inside a `Field`.';
|
|
24
|
+
function useFieldContext() {
|
|
25
|
+
var ctx = React.useContext(FieldContext);
|
|
26
|
+
|
|
27
|
+
if (!ctx) {
|
|
28
|
+
throw new Error(FIELD_CONTEXT_ERROR_MESSAGE);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return ctx;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
var _excluded = ["children"];
|
|
35
|
+
var __jsx = React__default["default"].createElement;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Using a [context](https://reactjs.org/docs/context.html), the field
|
|
39
|
+
* component connects the label, description, and message to the input element.
|
|
40
|
+
*/
|
|
41
|
+
var Field = /*#__PURE__*/React.forwardRef(function (_ref, forwardedRef) {
|
|
42
|
+
var children = _ref.children,
|
|
43
|
+
idProp = _ref.id,
|
|
44
|
+
data = _ref.data,
|
|
45
|
+
description = _ref.description,
|
|
46
|
+
_ref$disabled = _ref.disabled,
|
|
47
|
+
disabled = _ref$disabled === void 0 ? false : _ref$disabled,
|
|
48
|
+
label = _ref.label,
|
|
49
|
+
adornment = _ref.adornment,
|
|
50
|
+
_ref$labelVisibility = _ref.labelVisibility,
|
|
51
|
+
labelVisibility = _ref$labelVisibility === void 0 ? 'visible' : _ref$labelVisibility,
|
|
52
|
+
message = _ref.message,
|
|
53
|
+
secondaryLabel = _ref.secondaryLabel,
|
|
54
|
+
_ref$tone = _ref.tone,
|
|
55
|
+
tone = _ref$tone === void 0 ? 'neutral' : _ref$tone;
|
|
56
|
+
|
|
57
|
+
var _useFieldIds = useFieldIds(idProp),
|
|
58
|
+
descriptionId = _useFieldIds.descriptionId,
|
|
59
|
+
inputId = _useFieldIds.inputId,
|
|
60
|
+
messageId = _useFieldIds.messageId; // field context
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
var fieldContext = {
|
|
64
|
+
'aria-describedby': a11y.mergeIds(message && messageId, description && descriptionId),
|
|
65
|
+
id: inputId,
|
|
66
|
+
disabled: disabled,
|
|
67
|
+
invalid: Boolean(message && tone === 'critical')
|
|
68
|
+
}; // label prep
|
|
69
|
+
|
|
70
|
+
var hiddenLabel = __jsx(a11y.VisuallyHidden, {
|
|
71
|
+
as: "label",
|
|
72
|
+
htmlFor: inputId
|
|
73
|
+
}, label, " ", secondaryLabel);
|
|
74
|
+
|
|
75
|
+
var labelElement = {
|
|
76
|
+
hidden: hiddenLabel,
|
|
77
|
+
visible: __jsx(box.Box, {
|
|
78
|
+
as: "label",
|
|
79
|
+
htmlFor: inputId
|
|
80
|
+
}, __jsx(text.Text, {
|
|
81
|
+
inline: true,
|
|
82
|
+
tone: disabled ? 'disabled' : 'neutral',
|
|
83
|
+
weight: "strong"
|
|
84
|
+
}, label, ' ', secondaryLabel && __jsx(text.Text, {
|
|
85
|
+
inline: true,
|
|
86
|
+
tone: disabled ? 'disabled' : 'muted'
|
|
87
|
+
}, secondaryLabel))),
|
|
88
|
+
'reserve-space': __jsx(React.Fragment, null, hiddenLabel, __jsx(text.Text, {
|
|
89
|
+
inline: true,
|
|
90
|
+
"aria-hidden": true
|
|
91
|
+
}, "\xA0"))
|
|
92
|
+
};
|
|
93
|
+
return __jsx(FieldContextProvider, {
|
|
94
|
+
value: fieldContext
|
|
95
|
+
}, __jsx(stack.Stack, _extends({
|
|
96
|
+
gap: labelVisibility === 'hidden' ? undefined : 'small',
|
|
97
|
+
ref: forwardedRef
|
|
98
|
+
}, data ? internal.buildDataAttributes(data) : null), __jsx(box.Box, {
|
|
99
|
+
display: "flex",
|
|
100
|
+
alignItems: "center",
|
|
101
|
+
justifyContent: "spaceBetween",
|
|
102
|
+
gap: "large"
|
|
103
|
+
}, labelElement[labelVisibility], adornment), description && __jsx(text.Text, {
|
|
104
|
+
tone: "muted",
|
|
105
|
+
size: "small",
|
|
106
|
+
id: descriptionId
|
|
107
|
+
}, description), children, message && __jsx(FieldMessage, {
|
|
108
|
+
tone: tone,
|
|
109
|
+
id: messageId,
|
|
110
|
+
message: message
|
|
111
|
+
})));
|
|
112
|
+
});
|
|
113
|
+
Field.displayName = 'Field'; // Utils
|
|
114
|
+
// ------------------------------
|
|
115
|
+
|
|
116
|
+
function useFieldIds(id) {
|
|
117
|
+
var inputId = a11y.useId(id);
|
|
118
|
+
var descriptionId = a11y.composeId(inputId, 'description');
|
|
119
|
+
var messageId = a11y.composeId(inputId, 'message');
|
|
120
|
+
return {
|
|
121
|
+
descriptionId: descriptionId,
|
|
122
|
+
inputId: inputId,
|
|
123
|
+
messageId: messageId
|
|
124
|
+
};
|
|
125
|
+
} // Styled components
|
|
126
|
+
// ------------------------------
|
|
127
|
+
|
|
128
|
+
var messageToneMap = {
|
|
129
|
+
critical: 'critical',
|
|
130
|
+
neutral: 'muted',
|
|
131
|
+
positive: 'positive'
|
|
132
|
+
}; // NOTE: use icons in addition to color for folks with visions issues
|
|
133
|
+
|
|
134
|
+
var messageIconMap = {
|
|
135
|
+
critical: icon.ExclamationCircleIcon,
|
|
136
|
+
neutral: null,
|
|
137
|
+
positive: icon.CheckCircleIcon
|
|
138
|
+
};
|
|
139
|
+
var FieldMessage = function FieldMessage(_ref2) {
|
|
140
|
+
var message = _ref2.message,
|
|
141
|
+
id = _ref2.id,
|
|
142
|
+
tone = _ref2.tone;
|
|
143
|
+
var textTone = messageToneMap[tone];
|
|
144
|
+
var Icon = messageIconMap[tone];
|
|
145
|
+
return __jsx(box.Box, {
|
|
146
|
+
display: "flex",
|
|
147
|
+
gap: "xsmall"
|
|
148
|
+
}, Icon ? __jsx(IndicatorContainer, null, __jsx(Icon, {
|
|
149
|
+
size: "xxsmall",
|
|
150
|
+
tone: tone
|
|
151
|
+
})) : null, __jsx(text.Text, {
|
|
152
|
+
tone: textTone,
|
|
153
|
+
size: "small",
|
|
154
|
+
id: id
|
|
155
|
+
}, message));
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
function IndicatorContainer(_ref3) {
|
|
159
|
+
var children = _ref3.children,
|
|
160
|
+
props = _objectWithoutProperties(_ref3, _excluded);
|
|
161
|
+
|
|
162
|
+
var _useTheme = theme.useTheme(),
|
|
163
|
+
typography = _useTheme.typography,
|
|
164
|
+
utils = _useTheme.utils;
|
|
165
|
+
|
|
166
|
+
var _typography$text$smal = typography.text.small,
|
|
167
|
+
mobile = _typography$text$smal.mobile,
|
|
168
|
+
tablet = _typography$text$smal.tablet;
|
|
169
|
+
var responsiveStyles = utils.responsiveStyles({
|
|
170
|
+
mobile: {
|
|
171
|
+
height: mobile.capHeight
|
|
172
|
+
},
|
|
173
|
+
tablet: {
|
|
174
|
+
height: tablet.capHeight
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
return __jsx(box.Box, _extends({
|
|
178
|
+
display: "flex",
|
|
179
|
+
alignItems: "center",
|
|
180
|
+
"aria-hidden": true,
|
|
181
|
+
cursor: "default",
|
|
182
|
+
flexShrink: 0,
|
|
183
|
+
className: css.css(responsiveStyles)
|
|
184
|
+
}, props), children);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
exports.Field = Field;
|
|
188
|
+
exports.FieldMessage = FieldMessage;
|
|
189
|
+
exports.useFieldContext = useFieldContext;
|
|
190
|
+
exports.useFieldIds = useFieldIds;
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
Object.defineProperty(exports, '__esModule', { value: true });
|
|
4
|
+
|
|
5
|
+
var React = require('react');
|
|
6
|
+
var _objectWithoutProperties = require('@babel/runtime/helpers/esm/objectWithoutProperties');
|
|
7
|
+
var _extends = require('@babel/runtime/helpers/esm/extends');
|
|
8
|
+
var css = require('@emotion/css');
|
|
9
|
+
var a11y = require('@spark-web/a11y');
|
|
10
|
+
var box = require('@spark-web/box');
|
|
11
|
+
var icon = require('@spark-web/icon');
|
|
12
|
+
var stack = require('@spark-web/stack');
|
|
13
|
+
var text = require('@spark-web/text');
|
|
14
|
+
var theme = require('@spark-web/theme');
|
|
15
|
+
var internal = require('@spark-web/utils/internal');
|
|
16
|
+
|
|
17
|
+
function _interopDefault (e) { return e && e.__esModule ? e : { 'default': e }; }
|
|
18
|
+
|
|
19
|
+
var React__default = /*#__PURE__*/_interopDefault(React);
|
|
20
|
+
|
|
21
|
+
var FieldContext = /*#__PURE__*/React.createContext(null);
|
|
22
|
+
var FieldContextProvider = FieldContext.Provider;
|
|
23
|
+
var FIELD_CONTEXT_ERROR_MESSAGE = 'Input components must be inside a `Field`.';
|
|
24
|
+
function useFieldContext() {
|
|
25
|
+
var ctx = React.useContext(FieldContext);
|
|
26
|
+
|
|
27
|
+
if (!ctx) {
|
|
28
|
+
throw new Error(FIELD_CONTEXT_ERROR_MESSAGE);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return ctx;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
var _excluded = ["children"];
|
|
35
|
+
var __jsx = React__default["default"].createElement;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Using a [context](https://reactjs.org/docs/context.html), the field
|
|
39
|
+
* component connects the label, description, and message to the input element.
|
|
40
|
+
*/
|
|
41
|
+
var Field = /*#__PURE__*/React.forwardRef(function (_ref, forwardedRef) {
|
|
42
|
+
var children = _ref.children,
|
|
43
|
+
idProp = _ref.id,
|
|
44
|
+
data = _ref.data,
|
|
45
|
+
description = _ref.description,
|
|
46
|
+
_ref$disabled = _ref.disabled,
|
|
47
|
+
disabled = _ref$disabled === void 0 ? false : _ref$disabled,
|
|
48
|
+
label = _ref.label,
|
|
49
|
+
adornment = _ref.adornment,
|
|
50
|
+
_ref$labelVisibility = _ref.labelVisibility,
|
|
51
|
+
labelVisibility = _ref$labelVisibility === void 0 ? 'visible' : _ref$labelVisibility,
|
|
52
|
+
message = _ref.message,
|
|
53
|
+
secondaryLabel = _ref.secondaryLabel,
|
|
54
|
+
_ref$tone = _ref.tone,
|
|
55
|
+
tone = _ref$tone === void 0 ? 'neutral' : _ref$tone;
|
|
56
|
+
|
|
57
|
+
var _useFieldIds = useFieldIds(idProp),
|
|
58
|
+
descriptionId = _useFieldIds.descriptionId,
|
|
59
|
+
inputId = _useFieldIds.inputId,
|
|
60
|
+
messageId = _useFieldIds.messageId; // field context
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
var fieldContext = {
|
|
64
|
+
'aria-describedby': a11y.mergeIds(message && messageId, description && descriptionId),
|
|
65
|
+
id: inputId,
|
|
66
|
+
disabled: disabled,
|
|
67
|
+
invalid: Boolean(message && tone === 'critical')
|
|
68
|
+
}; // label prep
|
|
69
|
+
|
|
70
|
+
var hiddenLabel = __jsx(a11y.VisuallyHidden, {
|
|
71
|
+
as: "label",
|
|
72
|
+
htmlFor: inputId
|
|
73
|
+
}, label, " ", secondaryLabel);
|
|
74
|
+
|
|
75
|
+
var labelElement = {
|
|
76
|
+
hidden: hiddenLabel,
|
|
77
|
+
visible: __jsx(box.Box, {
|
|
78
|
+
as: "label",
|
|
79
|
+
htmlFor: inputId
|
|
80
|
+
}, __jsx(text.Text, {
|
|
81
|
+
inline: true,
|
|
82
|
+
tone: disabled ? 'disabled' : 'neutral',
|
|
83
|
+
weight: "strong"
|
|
84
|
+
}, label, ' ', secondaryLabel && __jsx(text.Text, {
|
|
85
|
+
inline: true,
|
|
86
|
+
tone: disabled ? 'disabled' : 'muted'
|
|
87
|
+
}, secondaryLabel))),
|
|
88
|
+
'reserve-space': __jsx(React.Fragment, null, hiddenLabel, __jsx(text.Text, {
|
|
89
|
+
inline: true,
|
|
90
|
+
"aria-hidden": true
|
|
91
|
+
}, "\xA0"))
|
|
92
|
+
};
|
|
93
|
+
return __jsx(FieldContextProvider, {
|
|
94
|
+
value: fieldContext
|
|
95
|
+
}, __jsx(stack.Stack, _extends({
|
|
96
|
+
gap: labelVisibility === 'hidden' ? undefined : 'small',
|
|
97
|
+
ref: forwardedRef
|
|
98
|
+
}, data ? internal.buildDataAttributes(data) : null), __jsx(box.Box, {
|
|
99
|
+
display: "flex",
|
|
100
|
+
alignItems: "center",
|
|
101
|
+
justifyContent: "spaceBetween",
|
|
102
|
+
gap: "large"
|
|
103
|
+
}, labelElement[labelVisibility], adornment), description && __jsx(text.Text, {
|
|
104
|
+
tone: "muted",
|
|
105
|
+
size: "small",
|
|
106
|
+
id: descriptionId
|
|
107
|
+
}, description), children, message && __jsx(FieldMessage, {
|
|
108
|
+
tone: tone,
|
|
109
|
+
id: messageId,
|
|
110
|
+
message: message
|
|
111
|
+
})));
|
|
112
|
+
});
|
|
113
|
+
Field.displayName = 'Field'; // Utils
|
|
114
|
+
// ------------------------------
|
|
115
|
+
|
|
116
|
+
function useFieldIds(id) {
|
|
117
|
+
var inputId = a11y.useId(id);
|
|
118
|
+
var descriptionId = a11y.composeId(inputId, 'description');
|
|
119
|
+
var messageId = a11y.composeId(inputId, 'message');
|
|
120
|
+
return {
|
|
121
|
+
descriptionId: descriptionId,
|
|
122
|
+
inputId: inputId,
|
|
123
|
+
messageId: messageId
|
|
124
|
+
};
|
|
125
|
+
} // Styled components
|
|
126
|
+
// ------------------------------
|
|
127
|
+
|
|
128
|
+
var messageToneMap = {
|
|
129
|
+
critical: 'critical',
|
|
130
|
+
neutral: 'muted',
|
|
131
|
+
positive: 'positive'
|
|
132
|
+
}; // NOTE: use icons in addition to color for folks with visions issues
|
|
133
|
+
|
|
134
|
+
var messageIconMap = {
|
|
135
|
+
critical: icon.ExclamationCircleIcon,
|
|
136
|
+
neutral: null,
|
|
137
|
+
positive: icon.CheckCircleIcon
|
|
138
|
+
};
|
|
139
|
+
var FieldMessage = function FieldMessage(_ref2) {
|
|
140
|
+
var message = _ref2.message,
|
|
141
|
+
id = _ref2.id,
|
|
142
|
+
tone = _ref2.tone;
|
|
143
|
+
var textTone = messageToneMap[tone];
|
|
144
|
+
var Icon = messageIconMap[tone];
|
|
145
|
+
return __jsx(box.Box, {
|
|
146
|
+
display: "flex",
|
|
147
|
+
gap: "xsmall"
|
|
148
|
+
}, Icon ? __jsx(IndicatorContainer, null, __jsx(Icon, {
|
|
149
|
+
size: "xxsmall",
|
|
150
|
+
tone: tone
|
|
151
|
+
})) : null, __jsx(text.Text, {
|
|
152
|
+
tone: textTone,
|
|
153
|
+
size: "small",
|
|
154
|
+
id: id
|
|
155
|
+
}, message));
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
function IndicatorContainer(_ref3) {
|
|
159
|
+
var children = _ref3.children,
|
|
160
|
+
props = _objectWithoutProperties(_ref3, _excluded);
|
|
161
|
+
|
|
162
|
+
var _useTheme = theme.useTheme(),
|
|
163
|
+
typography = _useTheme.typography,
|
|
164
|
+
utils = _useTheme.utils;
|
|
165
|
+
|
|
166
|
+
var _typography$text$smal = typography.text.small,
|
|
167
|
+
mobile = _typography$text$smal.mobile,
|
|
168
|
+
tablet = _typography$text$smal.tablet;
|
|
169
|
+
var responsiveStyles = utils.responsiveStyles({
|
|
170
|
+
mobile: {
|
|
171
|
+
height: mobile.capHeight
|
|
172
|
+
},
|
|
173
|
+
tablet: {
|
|
174
|
+
height: tablet.capHeight
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
return __jsx(box.Box, _extends({
|
|
178
|
+
display: "flex",
|
|
179
|
+
alignItems: "center",
|
|
180
|
+
"aria-hidden": true,
|
|
181
|
+
cursor: "default",
|
|
182
|
+
flexShrink: 0,
|
|
183
|
+
className: css.css(responsiveStyles)
|
|
184
|
+
}, props), children);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
exports.Field = Field;
|
|
188
|
+
exports.FieldMessage = FieldMessage;
|
|
189
|
+
exports.useFieldContext = useFieldContext;
|
|
190
|
+
exports.useFieldIds = useFieldIds;
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import React, { createContext, useContext, forwardRef, Fragment } from 'react';
|
|
2
|
+
import _objectWithoutProperties from '@babel/runtime/helpers/esm/objectWithoutProperties';
|
|
3
|
+
import _extends from '@babel/runtime/helpers/esm/extends';
|
|
4
|
+
import { css } from '@emotion/css';
|
|
5
|
+
import { mergeIds, VisuallyHidden, useId, composeId } from '@spark-web/a11y';
|
|
6
|
+
import { Box } from '@spark-web/box';
|
|
7
|
+
import { ExclamationCircleIcon, CheckCircleIcon } from '@spark-web/icon';
|
|
8
|
+
import { Stack } from '@spark-web/stack';
|
|
9
|
+
import { Text } from '@spark-web/text';
|
|
10
|
+
import { useTheme } from '@spark-web/theme';
|
|
11
|
+
import { buildDataAttributes } from '@spark-web/utils/internal';
|
|
12
|
+
|
|
13
|
+
var FieldContext = /*#__PURE__*/createContext(null);
|
|
14
|
+
var FieldContextProvider = FieldContext.Provider;
|
|
15
|
+
var FIELD_CONTEXT_ERROR_MESSAGE = 'Input components must be inside a `Field`.';
|
|
16
|
+
function useFieldContext() {
|
|
17
|
+
var ctx = useContext(FieldContext);
|
|
18
|
+
|
|
19
|
+
if (!ctx) {
|
|
20
|
+
throw new Error(FIELD_CONTEXT_ERROR_MESSAGE);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return ctx;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
var _excluded = ["children"];
|
|
27
|
+
var __jsx = React.createElement;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Using a [context](https://reactjs.org/docs/context.html), the field
|
|
31
|
+
* component connects the label, description, and message to the input element.
|
|
32
|
+
*/
|
|
33
|
+
var Field = /*#__PURE__*/forwardRef(function (_ref, forwardedRef) {
|
|
34
|
+
var children = _ref.children,
|
|
35
|
+
idProp = _ref.id,
|
|
36
|
+
data = _ref.data,
|
|
37
|
+
description = _ref.description,
|
|
38
|
+
_ref$disabled = _ref.disabled,
|
|
39
|
+
disabled = _ref$disabled === void 0 ? false : _ref$disabled,
|
|
40
|
+
label = _ref.label,
|
|
41
|
+
adornment = _ref.adornment,
|
|
42
|
+
_ref$labelVisibility = _ref.labelVisibility,
|
|
43
|
+
labelVisibility = _ref$labelVisibility === void 0 ? 'visible' : _ref$labelVisibility,
|
|
44
|
+
message = _ref.message,
|
|
45
|
+
secondaryLabel = _ref.secondaryLabel,
|
|
46
|
+
_ref$tone = _ref.tone,
|
|
47
|
+
tone = _ref$tone === void 0 ? 'neutral' : _ref$tone;
|
|
48
|
+
|
|
49
|
+
var _useFieldIds = useFieldIds(idProp),
|
|
50
|
+
descriptionId = _useFieldIds.descriptionId,
|
|
51
|
+
inputId = _useFieldIds.inputId,
|
|
52
|
+
messageId = _useFieldIds.messageId; // field context
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
var fieldContext = {
|
|
56
|
+
'aria-describedby': mergeIds(message && messageId, description && descriptionId),
|
|
57
|
+
id: inputId,
|
|
58
|
+
disabled: disabled,
|
|
59
|
+
invalid: Boolean(message && tone === 'critical')
|
|
60
|
+
}; // label prep
|
|
61
|
+
|
|
62
|
+
var hiddenLabel = __jsx(VisuallyHidden, {
|
|
63
|
+
as: "label",
|
|
64
|
+
htmlFor: inputId
|
|
65
|
+
}, label, " ", secondaryLabel);
|
|
66
|
+
|
|
67
|
+
var labelElement = {
|
|
68
|
+
hidden: hiddenLabel,
|
|
69
|
+
visible: __jsx(Box, {
|
|
70
|
+
as: "label",
|
|
71
|
+
htmlFor: inputId
|
|
72
|
+
}, __jsx(Text, {
|
|
73
|
+
inline: true,
|
|
74
|
+
tone: disabled ? 'disabled' : 'neutral',
|
|
75
|
+
weight: "strong"
|
|
76
|
+
}, label, ' ', secondaryLabel && __jsx(Text, {
|
|
77
|
+
inline: true,
|
|
78
|
+
tone: disabled ? 'disabled' : 'muted'
|
|
79
|
+
}, secondaryLabel))),
|
|
80
|
+
'reserve-space': __jsx(Fragment, null, hiddenLabel, __jsx(Text, {
|
|
81
|
+
inline: true,
|
|
82
|
+
"aria-hidden": true
|
|
83
|
+
}, "\xA0"))
|
|
84
|
+
};
|
|
85
|
+
return __jsx(FieldContextProvider, {
|
|
86
|
+
value: fieldContext
|
|
87
|
+
}, __jsx(Stack, _extends({
|
|
88
|
+
gap: labelVisibility === 'hidden' ? undefined : 'small',
|
|
89
|
+
ref: forwardedRef
|
|
90
|
+
}, data ? buildDataAttributes(data) : null), __jsx(Box, {
|
|
91
|
+
display: "flex",
|
|
92
|
+
alignItems: "center",
|
|
93
|
+
justifyContent: "spaceBetween",
|
|
94
|
+
gap: "large"
|
|
95
|
+
}, labelElement[labelVisibility], adornment), description && __jsx(Text, {
|
|
96
|
+
tone: "muted",
|
|
97
|
+
size: "small",
|
|
98
|
+
id: descriptionId
|
|
99
|
+
}, description), children, message && __jsx(FieldMessage, {
|
|
100
|
+
tone: tone,
|
|
101
|
+
id: messageId,
|
|
102
|
+
message: message
|
|
103
|
+
})));
|
|
104
|
+
});
|
|
105
|
+
Field.displayName = 'Field'; // Utils
|
|
106
|
+
// ------------------------------
|
|
107
|
+
|
|
108
|
+
function useFieldIds(id) {
|
|
109
|
+
var inputId = useId(id);
|
|
110
|
+
var descriptionId = composeId(inputId, 'description');
|
|
111
|
+
var messageId = composeId(inputId, 'message');
|
|
112
|
+
return {
|
|
113
|
+
descriptionId: descriptionId,
|
|
114
|
+
inputId: inputId,
|
|
115
|
+
messageId: messageId
|
|
116
|
+
};
|
|
117
|
+
} // Styled components
|
|
118
|
+
// ------------------------------
|
|
119
|
+
|
|
120
|
+
var messageToneMap = {
|
|
121
|
+
critical: 'critical',
|
|
122
|
+
neutral: 'muted',
|
|
123
|
+
positive: 'positive'
|
|
124
|
+
}; // NOTE: use icons in addition to color for folks with visions issues
|
|
125
|
+
|
|
126
|
+
var messageIconMap = {
|
|
127
|
+
critical: ExclamationCircleIcon,
|
|
128
|
+
neutral: null,
|
|
129
|
+
positive: CheckCircleIcon
|
|
130
|
+
};
|
|
131
|
+
var FieldMessage = function FieldMessage(_ref2) {
|
|
132
|
+
var message = _ref2.message,
|
|
133
|
+
id = _ref2.id,
|
|
134
|
+
tone = _ref2.tone;
|
|
135
|
+
var textTone = messageToneMap[tone];
|
|
136
|
+
var Icon = messageIconMap[tone];
|
|
137
|
+
return __jsx(Box, {
|
|
138
|
+
display: "flex",
|
|
139
|
+
gap: "xsmall"
|
|
140
|
+
}, Icon ? __jsx(IndicatorContainer, null, __jsx(Icon, {
|
|
141
|
+
size: "xxsmall",
|
|
142
|
+
tone: tone
|
|
143
|
+
})) : null, __jsx(Text, {
|
|
144
|
+
tone: textTone,
|
|
145
|
+
size: "small",
|
|
146
|
+
id: id
|
|
147
|
+
}, message));
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
function IndicatorContainer(_ref3) {
|
|
151
|
+
var children = _ref3.children,
|
|
152
|
+
props = _objectWithoutProperties(_ref3, _excluded);
|
|
153
|
+
|
|
154
|
+
var _useTheme = useTheme(),
|
|
155
|
+
typography = _useTheme.typography,
|
|
156
|
+
utils = _useTheme.utils;
|
|
157
|
+
|
|
158
|
+
var _typography$text$smal = typography.text.small,
|
|
159
|
+
mobile = _typography$text$smal.mobile,
|
|
160
|
+
tablet = _typography$text$smal.tablet;
|
|
161
|
+
var responsiveStyles = utils.responsiveStyles({
|
|
162
|
+
mobile: {
|
|
163
|
+
height: mobile.capHeight
|
|
164
|
+
},
|
|
165
|
+
tablet: {
|
|
166
|
+
height: tablet.capHeight
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
return __jsx(Box, _extends({
|
|
170
|
+
display: "flex",
|
|
171
|
+
alignItems: "center",
|
|
172
|
+
"aria-hidden": true,
|
|
173
|
+
cursor: "default",
|
|
174
|
+
flexShrink: 0,
|
|
175
|
+
className: css(responsiveStyles)
|
|
176
|
+
}, props), children);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export { Field, FieldMessage, useFieldContext, useFieldIds };
|
package/package.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@spark-web/field",
|
|
3
|
+
"license": "MIT",
|
|
4
|
+
"version": "1.0.0",
|
|
5
|
+
"main": "dist/spark-web-field.cjs.js",
|
|
6
|
+
"module": "dist/spark-web-field.esm.js",
|
|
7
|
+
"devDependencies": {
|
|
8
|
+
"@types/react": "^17.0.12"
|
|
9
|
+
},
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"@babel/runtime": "^7.14.6",
|
|
12
|
+
"@emotion/css": "^11.7.1",
|
|
13
|
+
"@spark-web/a11y": "^1.0.0",
|
|
14
|
+
"@spark-web/box": "^1.0.0",
|
|
15
|
+
"@spark-web/icon": "^1.0.0",
|
|
16
|
+
"@spark-web/stack": "^1.0.0",
|
|
17
|
+
"@spark-web/text": "^1.0.0",
|
|
18
|
+
"@spark-web/theme": "^1.0.0",
|
|
19
|
+
"@spark-web/utils": "^1.0.0",
|
|
20
|
+
"react": "^17.0.2"
|
|
21
|
+
},
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">= 14.13"
|
|
24
|
+
}
|
|
25
|
+
}
|
package/src/Field.tsx
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { css } from '@emotion/css';
|
|
2
|
+
import { composeId, mergeIds, useId, VisuallyHidden } from '@spark-web/a11y';
|
|
3
|
+
import { Box } from '@spark-web/box';
|
|
4
|
+
import { CheckCircleIcon, ExclamationCircleIcon } from '@spark-web/icon';
|
|
5
|
+
import { Stack } from '@spark-web/stack';
|
|
6
|
+
import { Text } from '@spark-web/text';
|
|
7
|
+
import { useTheme } from '@spark-web/theme';
|
|
8
|
+
import type { DataAttributeMap } from '@spark-web/utils/internal';
|
|
9
|
+
import { buildDataAttributes } from '@spark-web/utils/internal';
|
|
10
|
+
import type { ReactElement, ReactNode } from 'react';
|
|
11
|
+
import { forwardRef, Fragment } from 'react';
|
|
12
|
+
|
|
13
|
+
import { FieldContextProvider } from './context';
|
|
14
|
+
|
|
15
|
+
export type Tone = keyof typeof messageToneMap;
|
|
16
|
+
|
|
17
|
+
export type FieldProps = {
|
|
18
|
+
id?: string;
|
|
19
|
+
data?: DataAttributeMap;
|
|
20
|
+
|
|
21
|
+
/** Optionally provide a utility or contextual hint, related to the field. */
|
|
22
|
+
adornment?: ReactElement;
|
|
23
|
+
/** Input component */
|
|
24
|
+
children: ReactNode;
|
|
25
|
+
/**
|
|
26
|
+
* Indicates that the field is perceivable but disabled, so it is not editable
|
|
27
|
+
* or otherwise operable.
|
|
28
|
+
*/
|
|
29
|
+
disabled?: boolean;
|
|
30
|
+
/** Provide additional information that will aid user input. */
|
|
31
|
+
description?: string;
|
|
32
|
+
/** Concisely label the field. */
|
|
33
|
+
label: string;
|
|
34
|
+
/**
|
|
35
|
+
* The label must always be provided for assistive technology, but you may
|
|
36
|
+
* hide it from sighted users when the intent can be inferred from context.
|
|
37
|
+
*/
|
|
38
|
+
labelVisibility?: 'hidden' | 'reserve-space' | 'visible';
|
|
39
|
+
/** Provide a message, informing the user about changes in state. */
|
|
40
|
+
message?: string;
|
|
41
|
+
/** Additional context, typically used to indicate that the field is optional. */
|
|
42
|
+
secondaryLabel?: string;
|
|
43
|
+
/** Provide a tone to influence elements of the field, and its input. */
|
|
44
|
+
tone?: Tone;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Using a [context](https://reactjs.org/docs/context.html), the field
|
|
49
|
+
* component connects the label, description, and message to the input element.
|
|
50
|
+
*/
|
|
51
|
+
export const Field = forwardRef<HTMLDivElement, FieldProps>(
|
|
52
|
+
(
|
|
53
|
+
{
|
|
54
|
+
children,
|
|
55
|
+
id: idProp,
|
|
56
|
+
data,
|
|
57
|
+
|
|
58
|
+
description,
|
|
59
|
+
disabled = false,
|
|
60
|
+
label,
|
|
61
|
+
adornment,
|
|
62
|
+
labelVisibility = 'visible',
|
|
63
|
+
message,
|
|
64
|
+
secondaryLabel,
|
|
65
|
+
tone = 'neutral',
|
|
66
|
+
},
|
|
67
|
+
forwardedRef
|
|
68
|
+
) => {
|
|
69
|
+
const { descriptionId, inputId, messageId } = useFieldIds(idProp);
|
|
70
|
+
|
|
71
|
+
// field context
|
|
72
|
+
const fieldContext = {
|
|
73
|
+
'aria-describedby': mergeIds(
|
|
74
|
+
message && messageId,
|
|
75
|
+
description && descriptionId
|
|
76
|
+
),
|
|
77
|
+
id: inputId,
|
|
78
|
+
disabled,
|
|
79
|
+
invalid: Boolean(message && tone === 'critical'),
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
// label prep
|
|
83
|
+
const hiddenLabel = (
|
|
84
|
+
<VisuallyHidden as="label" htmlFor={inputId}>
|
|
85
|
+
{label} {secondaryLabel}
|
|
86
|
+
</VisuallyHidden>
|
|
87
|
+
);
|
|
88
|
+
const labelElement = {
|
|
89
|
+
hidden: hiddenLabel,
|
|
90
|
+
visible: (
|
|
91
|
+
<Box as="label" htmlFor={inputId}>
|
|
92
|
+
<Text inline tone={disabled ? 'disabled' : 'neutral'} weight="strong">
|
|
93
|
+
{label}{' '}
|
|
94
|
+
{secondaryLabel && (
|
|
95
|
+
<Text inline tone={disabled ? 'disabled' : 'muted'}>
|
|
96
|
+
{secondaryLabel}
|
|
97
|
+
</Text>
|
|
98
|
+
)}
|
|
99
|
+
</Text>
|
|
100
|
+
</Box>
|
|
101
|
+
),
|
|
102
|
+
'reserve-space': (
|
|
103
|
+
<Fragment>
|
|
104
|
+
{hiddenLabel}
|
|
105
|
+
<Text inline aria-hidden>
|
|
106
|
+
|
|
107
|
+
</Text>
|
|
108
|
+
</Fragment>
|
|
109
|
+
),
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
<FieldContextProvider value={fieldContext}>
|
|
114
|
+
<Stack
|
|
115
|
+
gap={labelVisibility === 'hidden' ? undefined : 'small'}
|
|
116
|
+
ref={forwardedRef}
|
|
117
|
+
{...(data ? buildDataAttributes(data) : null)}
|
|
118
|
+
>
|
|
119
|
+
<Box
|
|
120
|
+
display="flex"
|
|
121
|
+
alignItems="center"
|
|
122
|
+
justifyContent="spaceBetween"
|
|
123
|
+
gap="large"
|
|
124
|
+
>
|
|
125
|
+
{labelElement[labelVisibility]}
|
|
126
|
+
{adornment}
|
|
127
|
+
</Box>
|
|
128
|
+
|
|
129
|
+
{description && (
|
|
130
|
+
<Text tone="muted" size="small" id={descriptionId}>
|
|
131
|
+
{description}
|
|
132
|
+
</Text>
|
|
133
|
+
)}
|
|
134
|
+
|
|
135
|
+
{children}
|
|
136
|
+
|
|
137
|
+
{message && (
|
|
138
|
+
<FieldMessage tone={tone} id={messageId} message={message} />
|
|
139
|
+
)}
|
|
140
|
+
</Stack>
|
|
141
|
+
</FieldContextProvider>
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
);
|
|
145
|
+
Field.displayName = 'Field';
|
|
146
|
+
|
|
147
|
+
// Utils
|
|
148
|
+
// ------------------------------
|
|
149
|
+
|
|
150
|
+
export function useFieldIds(id?: string) {
|
|
151
|
+
const inputId = useId(id);
|
|
152
|
+
const descriptionId = composeId(inputId, 'description');
|
|
153
|
+
const messageId = composeId(inputId, 'message');
|
|
154
|
+
|
|
155
|
+
return { descriptionId, inputId, messageId };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Styled components
|
|
159
|
+
// ------------------------------
|
|
160
|
+
|
|
161
|
+
const messageToneMap = {
|
|
162
|
+
critical: 'critical',
|
|
163
|
+
neutral: 'muted',
|
|
164
|
+
positive: 'positive',
|
|
165
|
+
} as const;
|
|
166
|
+
|
|
167
|
+
// NOTE: use icons in addition to color for folks with visions issues
|
|
168
|
+
const messageIconMap = {
|
|
169
|
+
critical: ExclamationCircleIcon,
|
|
170
|
+
neutral: null,
|
|
171
|
+
positive: CheckCircleIcon,
|
|
172
|
+
} as const;
|
|
173
|
+
|
|
174
|
+
type FieldMessageProps = Required<Pick<FieldProps, 'message' | 'id' | 'tone'>>;
|
|
175
|
+
export const FieldMessage = ({ message, id, tone }: FieldMessageProps) => {
|
|
176
|
+
const textTone = messageToneMap[tone];
|
|
177
|
+
const Icon = messageIconMap[tone];
|
|
178
|
+
|
|
179
|
+
return (
|
|
180
|
+
<Box display="flex" gap="xsmall">
|
|
181
|
+
{Icon ? (
|
|
182
|
+
<IndicatorContainer>
|
|
183
|
+
<Icon size="xxsmall" tone={tone} />
|
|
184
|
+
</IndicatorContainer>
|
|
185
|
+
) : null}
|
|
186
|
+
<Text tone={textTone} size="small" id={id}>
|
|
187
|
+
{message}
|
|
188
|
+
</Text>
|
|
189
|
+
</Box>
|
|
190
|
+
);
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
function IndicatorContainer({ children, ...props }: { children: ReactNode }) {
|
|
194
|
+
const { typography, utils } = useTheme();
|
|
195
|
+
const { mobile, tablet } = typography.text.small;
|
|
196
|
+
const responsiveStyles = utils.responsiveStyles({
|
|
197
|
+
mobile: { height: mobile.capHeight },
|
|
198
|
+
tablet: { height: tablet.capHeight },
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
return (
|
|
202
|
+
<Box
|
|
203
|
+
display="flex"
|
|
204
|
+
alignItems="center"
|
|
205
|
+
aria-hidden
|
|
206
|
+
cursor="default"
|
|
207
|
+
flexShrink={0}
|
|
208
|
+
className={css(responsiveStyles)}
|
|
209
|
+
{...props}
|
|
210
|
+
>
|
|
211
|
+
{children}
|
|
212
|
+
</Box>
|
|
213
|
+
);
|
|
214
|
+
}
|
package/src/context.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { createContext, useContext } from 'react';
|
|
2
|
+
|
|
3
|
+
export type FieldContextType = {
|
|
4
|
+
'aria-describedby'?: string;
|
|
5
|
+
id: string;
|
|
6
|
+
disabled: boolean;
|
|
7
|
+
invalid: boolean;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export const FieldContext = createContext<FieldContextType | null>(null);
|
|
11
|
+
export const FieldContextProvider = FieldContext.Provider;
|
|
12
|
+
|
|
13
|
+
export const FIELD_CONTEXT_ERROR_MESSAGE =
|
|
14
|
+
'Input components must be inside a `Field`.';
|
|
15
|
+
|
|
16
|
+
export function useFieldContext() {
|
|
17
|
+
const ctx = useContext(FieldContext);
|
|
18
|
+
|
|
19
|
+
if (!ctx) {
|
|
20
|
+
throw new Error(FIELD_CONTEXT_ERROR_MESSAGE);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return ctx;
|
|
24
|
+
}
|