Skip to content

Commit db5c708

Browse files
committed
fix(ui-modal): make Modal's header non-sticky with small window height
Closes: INSTUI-4391
1 parent 9d28a3b commit db5c708

File tree

6 files changed

+95
-10
lines changed

6 files changed

+95
-10
lines changed

packages/ui-modal/src/Modal/ModalHeader/props.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ type ModalHeaderOwnProps = {
3636
children?: React.ReactNode
3737
variant?: 'default' | 'inverse'
3838
spacing?: 'default' | 'compact'
39+
smallViewPort?: boolean
3940
}
4041

4142
type ModalHeaderStyleProps = {
@@ -55,7 +56,8 @@ type ModalHeaderStyle = ComponentStyle<'modalHeader'>
5556
const propTypes: PropValidators<PropKeys> = {
5657
children: PropTypes.node,
5758
variant: PropTypes.oneOf(['default', 'inverse']),
58-
spacing: PropTypes.oneOf(['default', 'compact'])
59+
spacing: PropTypes.oneOf(['default', 'compact']),
60+
smallViewPort: PropTypes.bool
5961
}
6062

6163
const allowedProps: AllowedPropKeys = ['children', 'variant', 'spacing']

packages/ui-modal/src/Modal/ModalHeader/styles.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ const generateStyle = (
4444
props: ModalHeaderProps,
4545
state: ModalHeaderStyleProps
4646
): ModalHeaderStyle => {
47-
const { variant, spacing } = props
47+
const { variant, spacing, smallViewPort } = props
4848
const { withCloseButton } = state
4949

5050
const sizeVariants = {
@@ -83,6 +83,7 @@ const generateStyle = (
8383
borderBottomWidth: '0.0625rem',
8484
borderBottomStyle: 'solid',
8585
borderBottomColor: componentTheme.borderColor,
86+
...(smallViewPort ? { position: 'relative' } : {}),
8687
...sizeVariants[spacing!],
8788
...inverseStyle
8889
}

packages/ui-modal/src/Modal/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1180,6 +1180,7 @@ type: embed
11801180
<Figure.Item>When a user closes a modal, focus must return to a logical place within the page. This is usually the element that triggered opening the modal</Figure.Item>
11811181
<Figure.Item>Modals should be able to be closed by clicking away, esc key and/or a close button</Figure.Item>
11821182
<Figure.Item>We recommend that modals begin with a heading (typically H2)</Figure.Item>
1183+
<Figure.Item>The Modal's header currently becomes non-sticky when the window height is too small, improving navigation of the Modal.Body, e.g., at higher zoom levels</Figure.Item>
11831184
</Figure>
11841185
</Guidelines>
11851186
```

packages/ui-modal/src/Modal/index.tsx

Lines changed: 81 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import { Children, Component, isValidElement, ReactElement } from 'react'
2828
import { passthroughProps, safeCloneElement } from '@instructure/ui-react-utils'
2929
import { createChainedFunction } from '@instructure/ui-utils'
3030
import { testable } from '@instructure/ui-testable'
31+
import { canUseDOM } from '@instructure/ui-dom-utils'
3132

3233
import { Transition } from '@instructure/ui-motion'
3334
import { Portal } from '@instructure/ui-portal'
@@ -86,7 +87,8 @@ class Modal extends Component<ModalProps, ModalState> {
8687

8788
this.state = {
8889
transitioning: false,
89-
open: props.open ?? false
90+
open: props.open ?? false,
91+
windowHeight: 99999
9092
}
9193
}
9294

@@ -101,6 +103,7 @@ class Modal extends Component<ModalProps, ModalState> {
101103

102104
componentDidMount() {
103105
this.props.makeStyles?.()
106+
window.addEventListener('resize', this.updateHeight)
104107
}
105108

106109
componentDidUpdate(prevProps: ModalProps) {
@@ -110,6 +113,14 @@ class Modal extends Component<ModalProps, ModalState> {
110113
this.props.makeStyles?.()
111114
}
112115

116+
componentWillUnmount() {
117+
window.removeEventListener('resize', this.updateHeight)
118+
}
119+
120+
updateHeight = () => {
121+
this.setState({ windowHeight: window.innerHeight })
122+
}
123+
113124
get defaultFocusElement() {
114125
return this.props.defaultFocusElement
115126
}
@@ -146,21 +157,84 @@ class Modal extends Component<ModalProps, ModalState> {
146157
}
147158
}
148159

160+
getWindowHeightInRem = (): number => {
161+
if (!canUseDOM) {
162+
return Infinity
163+
}
164+
const rootFontSize = parseFloat(
165+
getComputedStyle(document.documentElement)?.fontSize || '16'
166+
)
167+
if (isNaN(rootFontSize) || rootFontSize <= 0) {
168+
return Infinity
169+
}
170+
return window.innerHeight / rootFontSize
171+
}
172+
149173
renderChildren() {
150174
const { children, variant, overflow } = this.props
151175

176+
// header should be non-sticky for small viewport height (ca. 320px)
177+
if (this.getWindowHeightInRem() <= 20) {
178+
return this.renderForSmallViewportHeight()
179+
}
180+
152181
return Children.map(children as ReactElement, (child) => {
153182
if (!child) return // ignore null, falsy children
183+
return this.cloneChildWithProps(child, variant, overflow)
184+
})
185+
}
186+
187+
renderForSmallViewportHeight() {
188+
const { children, variant, overflow, styles } = this.props
154189

190+
const headerAndBody: React.ReactNode[] = []
191+
192+
const childrenArray = Children.toArray(children)
193+
194+
// Separate header and body elements
195+
const filteredChildren = childrenArray.filter((child) => {
155196
if (isValidElement(child)) {
156-
return safeCloneElement(child, {
157-
variant: variant,
158-
overflow: (child?.props as { overflow: string })?.overflow || overflow
159-
})
160-
} else {
161-
return child
197+
if (child.type === Modal.Header || child.type === Modal.Body) {
198+
if (child.type === Modal.Header) {
199+
const headerWithProp = safeCloneElement(child, {
200+
smallViewPort: true
201+
})
202+
headerAndBody.push(headerWithProp)
203+
} else {
204+
headerAndBody.push(child)
205+
}
206+
return false
207+
}
162208
}
209+
return true
163210
})
211+
212+
// Adds the <div> to the beginning of the filteredChildren array
213+
if (headerAndBody.length > 0) {
214+
filteredChildren.unshift(
215+
<div css={styles?.joinedHeaderAndBody}>{headerAndBody}</div>
216+
)
217+
}
218+
219+
return Children.map(filteredChildren as ReactElement[], (child) => {
220+
if (!child) return // ignore null, falsy children
221+
return this.cloneChildWithProps(child, variant, overflow)
222+
})
223+
}
224+
225+
cloneChildWithProps(
226+
child: React.ReactNode,
227+
variant: string | undefined,
228+
overflow: string | undefined
229+
) {
230+
if (isValidElement(child)) {
231+
return safeCloneElement(child, {
232+
variant: variant,
233+
overflow: (child?.props as { overflow: string })?.overflow || overflow
234+
})
235+
} else {
236+
return child
237+
}
164238
}
165239

166240
renderDialog(

packages/ui-modal/src/Modal/props.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -189,11 +189,14 @@ type ModalProps = ModalOwnProps &
189189
WithStyleProps<ModalTheme, ModalStyle> &
190190
OtherHTMLAttributes<ModalOwnProps>
191191

192-
type ModalStyle = ComponentStyle<'modal' | 'constrainContext'>
192+
type ModalStyle = ComponentStyle<
193+
'modal' | 'constrainContext' | 'joinedHeaderAndBody'
194+
>
193195

194196
type ModalState = {
195197
transitioning: boolean
196198
open: boolean
199+
windowHeight: number
197200
}
198201

199202
const propTypes: PropValidators<PropKeys> = {

packages/ui-modal/src/Modal/styles.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,10 @@ const generateStyle = (
108108
position: 'relative',
109109
width: '100%',
110110
height: '100%'
111+
},
112+
joinedHeaderAndBody: {
113+
borderRadius: componentTheme.borderRadius,
114+
overflowY: 'scroll'
111115
}
112116
}
113117
}

0 commit comments

Comments
 (0)