@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 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,4 @@
1
+ export { useFieldContext } from './context';
2
+ export { Field, FieldMessage, useFieldIds } from './Field';
3
+ export type { FieldContextType } from './context';
4
+ export type { FieldProps, Tone } from './Field';
@@ -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,7 @@
1
+ 'use strict';
2
+
3
+ if (process.env.NODE_ENV === "production") {
4
+ module.exports = require("./spark-web-field.cjs.prod.js");
5
+ } else {
6
+ module.exports = require("./spark-web-field.cjs.dev.js");
7
+ }
@@ -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
+ &nbsp;
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
+ }
package/src/index.ts ADDED
@@ -0,0 +1,7 @@
1
+ export { useFieldContext } from './context';
2
+ export { Field, FieldMessage, useFieldIds } from './Field';
3
+
4
+ // types
5
+
6
+ export type { FieldContextType } from './context';
7
+ export type { FieldProps, Tone } from './Field';