@instructure/ui-form-field 11.7.9-snapshot-5 → 11.7.10

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.
@@ -34,7 +34,8 @@ import { Grid } from '@instructure/ui-grid/latest'
34
34
  import {
35
35
  pickProps,
36
36
  omitProps,
37
- safeCloneElement
37
+ safeCloneElement,
38
+ withDeterministicId
38
39
  } from '@instructure/ui-react-utils'
39
40
  import { withStyleNew } from '@instructure/emotion'
40
41
 
@@ -46,12 +47,14 @@ import generateStyle from './styles.js'
46
47
 
47
48
  import { allowedProps } from './props.js'
48
49
  import type { FormFieldGroupProps } from './props'
50
+ import type { FormFieldLayoutChildrenParams } from '../../FormFieldLayout/v2/props'
49
51
 
50
52
  /**
51
53
  ---
52
54
  category: components
53
55
  ---
54
56
  **/
57
+ @withDeterministicId()
55
58
  @withStyleNew(generateStyle)
56
59
  class FormFieldGroup extends Component<FormFieldGroupProps> {
57
60
  static displayName = 'FormFieldGroup'
@@ -67,6 +70,13 @@ class FormFieldGroup extends Component<FormFieldGroupProps> {
67
70
  isGroup: true
68
71
  }
69
72
 
73
+ constructor(props: FormFieldGroupProps) {
74
+ super(props)
75
+ this._defaultId = props.deterministicId!()
76
+ }
77
+
78
+ private readonly _defaultId: string
79
+
70
80
  ref: Element | null = null
71
81
 
72
82
  handleRef = (el: Element | null) => {
@@ -96,9 +106,10 @@ class FormFieldGroup extends Component<FormFieldGroupProps> {
96
106
  )
97
107
  }
98
108
 
99
- renderColumns() {
100
- const { disabled } = this.props
101
- return Children.map(this.props.children, (child, index) => {
109
+ renderColumns(params: FormFieldLayoutChildrenParams) {
110
+ const { disabled, children } = this.props
111
+ const fields = typeof children === 'function' ? children(params) : children
112
+ return Children.map(fields, (child, index) => {
102
113
  if (!child) return null
103
114
  const el = child as ReactElement<any>
104
115
  const renderedChild =
@@ -116,7 +127,7 @@ class FormFieldGroup extends Component<FormFieldGroupProps> {
116
127
  })
117
128
  }
118
129
 
119
- renderChildren() {
130
+ renderChildren(params: FormFieldLayoutChildrenParams) {
120
131
  return (
121
132
  <Grid
122
133
  colSpacing={this.props.colSpacing}
@@ -127,17 +138,17 @@ class FormFieldGroup extends Component<FormFieldGroupProps> {
127
138
  (this.props.layout === 'columns' ? 'medium' : null)
128
139
  }
129
140
  >
130
- <Grid.Row>{this.renderColumns()}</Grid.Row>
141
+ <Grid.Row>{this.renderColumns(params)}</Grid.Row>
131
142
  </Grid>
132
143
  )
133
144
  }
134
145
 
135
- renderFields() {
146
+ renderFields(params: FormFieldLayoutChildrenParams) {
136
147
  const { styles } = this.props
137
148
 
138
149
  return (
139
150
  <span key="fields" css={styles?.formFieldGroup}>
140
- {this.renderChildren()}
151
+ {this.renderChildren(params)}
141
152
  </span>
142
153
  )
143
154
  }
@@ -180,12 +191,13 @@ class FormFieldGroup extends Component<FormFieldGroupProps> {
180
191
  vAlign={props.vAlign}
181
192
  layout={props.layout === 'inline' ? 'inline' : 'stacked'}
182
193
  label={props.description}
194
+ id={props.id || this._defaultId}
183
195
  aria-disabled={props.disabled ? 'true' : undefined}
184
196
  aria-invalid={ariaInvalid}
185
197
  elementRef={this.handleRef}
186
198
  isGroup={isGroup}
187
199
  >
188
- {this.renderFields()}
200
+ {(params: FormFieldLayoutChildrenParams) => this.renderFields(params)}
189
201
  </FormFieldLayout>
190
202
  )
191
203
  }
@@ -31,8 +31,12 @@ import type {
31
31
  ComponentStyle,
32
32
  Spacing
33
33
  } from '@instructure/emotion'
34
- import type { FormFieldLayoutOwnProps } from '../../FormFieldLayout/v2/props'
34
+ import type {
35
+ FormFieldLayoutChildren,
36
+ FormFieldLayoutOwnProps
37
+ } from '../../FormFieldLayout/v2/props'
35
38
  import type { FormMessage } from '../../utils/v1/FormPropTypes'
39
+ import type { WithDeterministicIdProps } from '@instructure/ui-react-utils'
36
40
 
37
41
  type FormFieldGroupOwnProps = {
38
42
  description: React.ReactNode
@@ -60,7 +64,11 @@ type FormFieldGroupOwnProps = {
60
64
  * Whether the field group is read-only. When true, error and success messages will be hidden.
61
65
  */
62
66
  readOnly?: boolean
63
- children?: React.ReactNode
67
+ /**
68
+ * The form controls. Can be a function that receives `{ describedBy }`, so
69
+ * the controls can reference the group messages via `aria-describedby`.
70
+ */
71
+ children?: FormFieldLayoutChildren
64
72
  layout?: 'stacked' | 'columns' | 'inline'
65
73
  rowSpacing?: 'none' | 'small' | 'medium' | 'large'
66
74
  colSpacing?: 'none' | 'small' | 'medium' | 'large'
@@ -85,11 +93,18 @@ type AllowedPropKeys = Readonly<Array<PropKeys>>
85
93
  type FormFieldGroupProps = FormFieldGroupOwnProps &
86
94
  WithStyleProps<null, FormFieldGroupStyle> &
87
95
  OtherHTMLAttributes<FormFieldGroupOwnProps> &
96
+ WithDeterministicIdProps &
88
97
  // Adding other props that can be passed to FormFieldLayout,
89
98
  // excluding the ones we set manually
90
99
  Omit<
91
100
  FormFieldLayoutOwnProps,
92
- 'messages' | 'messagesId' | 'vAlign' | 'layout' | 'label' | 'children'
101
+ | 'id'
102
+ | 'messages'
103
+ | 'messagesId'
104
+ | 'vAlign'
105
+ | 'layout'
106
+ | 'label'
107
+ | 'children'
93
108
  >
94
109
 
95
110
  type FormFieldGroupStyle = ComponentStyle<'formFieldGroup'>
@@ -32,6 +32,27 @@ import generateStyle from './styles.js'
32
32
  import { allowedProps } from './props.js'
33
33
  import type { FormFieldLayoutProps } from './props'
34
34
 
35
+ // Elements that handle clicks themselves, do not override it
36
+ const INTERACTIVE_SELECTOR = [
37
+ 'a[href]',
38
+ 'button',
39
+ 'input',
40
+ 'select',
41
+ 'textarea',
42
+ 'label',
43
+ 'summary',
44
+ 'iframe',
45
+ 'embed',
46
+ 'object',
47
+ 'audio[controls]',
48
+ 'video[controls]',
49
+ '[contenteditable]:not([contenteditable="false"])',
50
+ '[tabindex]'
51
+ ].join(',')
52
+
53
+ // Clicks already forwarded by a nested FormFieldLayout
54
+ const forwardedClicks = new WeakSet<Event>()
55
+
35
56
  /**
36
57
  ---
37
58
  parent: FormField
@@ -42,10 +63,11 @@ const FormFieldLayout = forwardRef<Element, FormFieldLayoutProps>(
42
63
  const {
43
64
  inline = false,
44
65
  layout = 'stacked',
45
- as = 'label',
66
+ as = 'div',
46
67
  labelAlign = 'end',
47
68
  vAlign,
48
69
  label,
70
+ id,
49
71
  messages,
50
72
  messagesId: messagesIdProp,
51
73
  labelId: labelIdProp,
@@ -107,6 +129,8 @@ const FormFieldLayout = forwardRef<Element, FormFieldLayoutProps>(
107
129
 
108
130
  const hasVisibleLabel = label ? hasVisibleChildren(label) : false
109
131
 
132
+ const describedBy = hasMessages ? messagesId : undefined
133
+
110
134
  const invalid = !!filteredMessages?.find(
111
135
  (m) => m.type === 'error' || m.type === 'newError'
112
136
  )
@@ -159,6 +183,31 @@ const FormFieldLayout = forwardRef<Element, FormFieldLayoutProps>(
159
183
  [inputContainerRef]
160
184
  )
161
185
 
186
+ // Clicks on the rest of the control area (icons, before/after content) need
187
+ // to be forwarded to the control.
188
+ const handleChildrenClick = (e: React.MouseEvent<HTMLElement>) => {
189
+ if (
190
+ // it's a group, no clear control to forward to
191
+ ElementType === 'fieldset' ||
192
+ e.defaultPrevented ||
193
+ forwardedClicks.has(e.nativeEvent)
194
+ ) {
195
+ return
196
+ }
197
+ const container = e.currentTarget
198
+ const interactive = (e.target as Element).closest(INTERACTIVE_SELECTOR)
199
+ if (interactive && container.contains(interactive)) {
200
+ return // do not steal clicks for e.g. DateInput's calendar icon
201
+ }
202
+ const control = id ? container.ownerDocument.getElementById(id) : null
203
+ if (!control || !container.contains(control)) {
204
+ return // no control
205
+ }
206
+ forwardedClicks.add(e.nativeEvent)
207
+ control.focus()
208
+ control.click()
209
+ }
210
+
162
211
  const renderLabel = () => {
163
212
  const labelContent = hasVisibleLabel ? (
164
213
  <>
@@ -188,9 +237,9 @@ const FormFieldLayout = forwardRef<Element, FormFieldLayoutProps>(
188
237
  )
189
238
  }
190
239
  return (
191
- <span css={styles?.formFieldLabel} id={labelId}>
240
+ <label css={styles?.formFieldLabel} id={labelId} htmlFor={id}>
192
241
  {labelContent}
193
- </span>
242
+ </label>
194
243
  )
195
244
  } else if (label) {
196
245
  if (ElementType === 'fieldset') {
@@ -200,11 +249,10 @@ const FormFieldLayout = forwardRef<Element, FormFieldLayoutProps>(
200
249
  </legend>
201
250
  )
202
251
  }
203
- // needs to be wrapped because it needs an `id`
204
252
  return (
205
- <div id={labelId} style={{ display: 'contents' }}>
253
+ <label id={labelId} htmlFor={id} style={{ display: 'contents' }}>
206
254
  {label}
207
- </div>
255
+ </label>
208
256
  )
209
257
  } else return null
210
258
  }
@@ -230,8 +278,16 @@ const FormFieldLayout = forwardRef<Element, FormFieldLayoutProps>(
230
278
  >
231
279
  {renderLabel()}
232
280
  {isGroup && renderMessages()}
233
- <span css={styles?.formFieldChildren} ref={handleInputContainerRef}>
234
- {children}
281
+ {/* Clicks are forwarded to the control. Needed for e.g. Select's down arrow */}
282
+ {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
283
+ <span
284
+ css={styles?.formFieldChildren}
285
+ ref={handleInputContainerRef}
286
+ onClick={handleChildrenClick}
287
+ >
288
+ {typeof children === 'function'
289
+ ? children({ describedBy })
290
+ : children}
235
291
  </span>
236
292
  {!isGroup && renderMessages()}
237
293
  </ElementType>
@@ -35,14 +35,38 @@ import type { FormMessage } from '../../utils/v1/FormPropTypes'
35
35
  import type { WithDeterministicIdProps } from '@instructure/ui-react-utils'
36
36
  import type { NewComponentTypes } from '@instructure/ui-themes'
37
37
 
38
+ type FormFieldLayoutChildrenParams = {
39
+ /**
40
+ * The id of the rendered messages, or `undefined` when no messages are
41
+ * shown. Add it to the control's `aria-describedby`.
42
+ */
43
+ describedBy?: string
44
+ }
45
+
46
+ type FormFieldLayoutChildren =
47
+ | React.ReactNode
48
+ | ((params: FormFieldLayoutChildrenParams) => React.ReactNode)
49
+
38
50
  type FormFieldLayoutOwnProps = {
51
+ /**
52
+ * The label text. Will be appended by a `*` if this field is required.
53
+ */
39
54
  label: React.ReactNode
40
55
  /**
41
- * the id of the input (to link it to its label for a11y)
56
+ * The id of the form control (=the child of this component). It's applied as the
57
+ * [for](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Attributes/for)
58
+ * attribute of the label, so the control needs to have the same `id`.
59
+ * Not used when rendering as a `fieldset` (group)
42
60
  */
43
- id?: string
61
+ id: string
44
62
  /**
45
- * the element type to render as
63
+ * The element type to render as, `div` be default.
64
+ *
65
+ * Also determines how the `label` will be rendered:
66
+ * If its value is `fieldset` (for form groups), it be rendered in a
67
+ * [legend](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/legend),
68
+ * for all other cases it will be in a
69
+ * [label](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/label).
46
70
  */
47
71
  as?: AsElementType
48
72
  /**
@@ -57,11 +81,18 @@ type FormFieldLayoutOwnProps = {
57
81
  */
58
82
  messagesId?: string
59
83
  /**
60
- * id for the label element, so a single form control can reference just the
61
- * label text via `aria-labelledby` (keeping messages out of its accessible name)
84
+ * `id` for the label/legend element. Useful when the control is not a
85
+ * [labelable](https://developer.mozilla.org/en-US/docs/Web/HTML/Guides/Content_categories#labelable)
86
+ * element (e.g. a custom widget), and needs to reference the label via
87
+ * `aria-labelledby={labelId}`.
62
88
  */
63
89
  labelId?: string
64
- children?: React.ReactNode
90
+ /**
91
+ * The form control(s). Can be a function that receives
92
+ * `{ describedBy }`, so the control can reference the messages via
93
+ * `aria-describedby`.
94
+ */
95
+ children?: FormFieldLayoutChildren
65
96
  /**
66
97
  * If `true` use an inline layout -- content will flow on the left/right side
67
98
  * of this component
@@ -157,6 +188,8 @@ type FormFieldStyleProps = {
157
188
  }
158
189
 
159
190
  export type {
191
+ FormFieldLayoutChildren,
192
+ FormFieldLayoutChildrenParams,
160
193
  FormFieldStyleProps,
161
194
  FormFieldLayoutProps,
162
195
  FormFieldLayoutStyle,
@@ -121,6 +121,7 @@ const generateStyle = (
121
121
  fontWeight: componentTheme.fontWeight,
122
122
  fontSize: componentTheme.fontSize,
123
123
  lineHeight: componentTheme.lineHeight,
124
+ paddingBottom: hasVisibleMessage ? '0' : componentTheme.gapPrimitives,
124
125
  ...(isInlineLayout && {
125
126
  margin: '0',
126
127
  // when inline add a small padding between the label and the control
@@ -179,8 +180,7 @@ const generateStyle = (
179
180
  ...labelStyles,
180
181
  // NOTE: needs separate groups for `:is()` and `:-webkit-any()` because of css selector group validation (see https://www.w3.org/TR/selectors-3/#grouping)
181
182
  '&:is(label)': labelStyles,
182
- '&:-webkit-any(label)': labelStyles,
183
- paddingBottom: hasVisibleMessage ? '0' : componentTheme.gapPrimitives
183
+ '&:-webkit-any(label)': labelStyles
184
184
  },
185
185
  formFieldChildren: {
186
186
  label: 'formFieldLayout__children',
package/src/exports/b.ts CHANGED
@@ -36,5 +36,8 @@ export type {
36
36
  export type { FormFieldOwnProps, FormFieldProps } from '../FormField/v2/props'
37
37
  export type { FormFieldMessageProps } from '../FormFieldMessage/v2/props'
38
38
  export type { FormFieldMessagesProps } from '../FormFieldMessages/v2/props'
39
- export type { FormFieldLayoutProps } from '../FormFieldLayout/v2/props'
39
+ export type {
40
+ FormFieldLayoutProps,
41
+ FormFieldLayoutChildrenParams
42
+ } from '../FormFieldLayout/v2/props'
40
43
  export type { FormFieldGroupProps } from '../FormFieldGroup/v2/props'