Skip to content

Commit a49806b

Browse files
authored
Enable disabled reason tooltip on button (#1293)
* Enable disabled reason tooltip on button * Make tooltip component wrappable around any element * Add example of disable button tooltips * Only have one tooltip usage method
1 parent b278c92 commit a49806b

File tree

7 files changed

+106
-51
lines changed

7 files changed

+106
-51
lines changed

app/pages/project/instances/instance/tabs/StorageTab.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ export function StorageTab() {
136136
variant="default"
137137
size="sm"
138138
onClick={() => setShowDiskCreate(true)}
139+
disabledReason="Instance must be stopped to create a disk"
139140
disabled={!instanceStopped}
140141
>
141142
Create new disk
@@ -145,6 +146,7 @@ export function StorageTab() {
145146
color="secondary"
146147
size="sm"
147148
onClick={() => setShowDiskAttach(true)}
149+
disabledReason="Instance must be stopped to attach a disk"
148150
disabled={!instanceStopped}
149151
>
150152
Attach existing disk

libs/ui/lib/button/Button.tsx

Lines changed: 26 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import cn from 'classnames'
22
import type { MouseEventHandler } from 'react'
33
import { forwardRef } from 'react'
44

5-
import { Spinner } from '@oxide/ui'
5+
import { Spinner, Tooltip, Wrap } from '@oxide/ui'
66
import { assertUnreachable } from '@oxide/util'
77

88
import './button.css'
@@ -86,6 +86,7 @@ export type ButtonProps = Pick<
8686
ButtonStyleProps & {
8787
innerClassName?: string
8888
loading?: boolean
89+
disabledReason?: string
8990
}
9091

9192
export const buttonStyle = ({
@@ -129,31 +130,36 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
129130
disabled,
130131
onClick,
131132
'aria-disabled': ariaDisabled,
133+
disabledReason,
132134
form,
133135
title,
134136
},
135137
ref
136138
) => {
137139
return (
138-
<button
139-
className={cn(buttonStyle({ size, variant, color }), className, {
140-
'visually-disabled': disabled,
141-
})}
142-
ref={ref}
143-
type={type}
144-
onMouseDown={disabled ? noop : undefined}
145-
onClick={disabled ? noop : onClick}
146-
aria-disabled={disabled || ariaDisabled}
147-
form={form}
148-
title={title}
149-
>
150-
<>
151-
{loading && <Spinner className="absolute" />}
152-
<span className={cn('flex items-center', innerClassName, { invisible: loading })}>
153-
{children}
154-
</span>
155-
</>
156-
</button>
140+
<Wrap when={disabled && disabledReason} with={<Tooltip content={disabledReason!} />}>
141+
<button
142+
className={cn(buttonStyle({ size, variant, color }), className, {
143+
'visually-disabled': disabled,
144+
})}
145+
ref={ref}
146+
type={type}
147+
onMouseDown={disabled ? noop : undefined}
148+
onClick={disabled ? noop : onClick}
149+
aria-disabled={disabled || ariaDisabled}
150+
form={form}
151+
title={title}
152+
>
153+
<>
154+
{loading && <Spinner className="absolute" />}
155+
<span
156+
className={cn('flex items-center', innerClassName, { invisible: loading })}
157+
>
158+
{children}
159+
</span>
160+
</>
161+
</button>
162+
</Wrap>
157163
)
158164
}
159165
)

libs/ui/lib/field-label/FieldLabel.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,10 @@ export const FieldLabel = ({
3232
)}
3333
</Component>
3434
{tip && (
35-
<Tooltip id={`${id}-tip`} content={tip}>
36-
<Info8Icon />
35+
<Tooltip content={tip}>
36+
<button className="svg:pointer-events-none">
37+
<Info8Icon />
38+
</button>
3739
</Tooltip>
3840
)}
3941
</div>

libs/ui/lib/tooltip/Tooltip.stories.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ import { Filter12Icon } from '../icons'
22
import { Tooltip } from './Tooltip'
33

44
export const Default = () => (
5-
<Tooltip id="tooltip" content="Filter" onClick={() => alert('onClick')}>
6-
<Filter12Icon />
5+
<Tooltip content="Filter">
6+
<button>
7+
<Filter12Icon />
8+
</button>
79
</Tooltip>
810
)

libs/ui/lib/tooltip/Tooltip.tsx

Lines changed: 54 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,13 @@ import {
1414
} from '@floating-ui/react-dom-interactions'
1515
import type { Placement } from '@floating-ui/react-dom-interactions'
1616
import cn from 'classnames'
17+
import type { ReactElement } from 'react'
18+
import { forwardRef } from 'react'
19+
import { cloneElement } from 'react'
20+
import { Children } from 'react'
1721
import { useRef, useState } from 'react'
22+
import { mergeRefs } from 'react-merge-refs'
23+
import invariant from 'tiny-invariant'
1824

1925
import './tooltip.css'
2026

@@ -26,23 +32,13 @@ import './tooltip.css'
2632
*/
2733
type PlacementOrAuto = Placement | 'auto'
2834

29-
export interface TooltipProps {
30-
id: string
31-
children?: React.ReactNode
32-
/** The text to appear on hover/focus */
35+
export interface UseTooltipOptions {
36+
/** Text to be rendered inside the tooltip */
3337
content: string | React.ReactNode
34-
onClick?: React.MouseEventHandler<HTMLButtonElement>
35-
definition?: boolean
3638
/** Defaults to auto if not supplied */
3739
placement?: PlacementOrAuto
3840
}
39-
40-
export const Tooltip = ({
41-
children,
42-
content,
43-
placement = 'auto',
44-
definition = false,
45-
}: TooltipProps) => {
41+
export const useTooltip = ({ content, placement }: UseTooltipOptions) => {
4642
const [open, setOpen] = useState(false)
4743
const arrowRef = useRef(null)
4844

@@ -80,18 +76,15 @@ export const Tooltip = ({
8076
useRole(context, { role: 'tooltip' }),
8177
])
8278

83-
return (
84-
<>
85-
<button
86-
type="button"
87-
ref={reference}
88-
{...getReferenceProps()}
89-
className={cn('svg:pointer-events-none', {
90-
'dashed-underline': definition,
91-
})}
92-
>
93-
{children}
94-
</button>
79+
return {
80+
/**
81+
* Ref to be added to the anchor element of the tooltip. Use
82+
* `react-merge-refs` if more than one ref is required for the element.
83+
* */
84+
ref: reference,
85+
/** Props to be passed to the anchor element of the tooltip */
86+
props: getReferenceProps(),
87+
Tooltip: () => (
9588
<FloatingPortal>
9689
{open && (
9790
<div
@@ -111,6 +104,40 @@ export const Tooltip = ({
111104
</div>
112105
)}
113106
</FloatingPortal>
114-
</>
115-
)
107+
),
108+
}
109+
}
110+
export interface TooltipProps {
111+
children?: React.ReactNode
112+
/** The text to appear on hover/focus */
113+
content: string | React.ReactNode
114+
/** Defaults to auto if not supplied */
115+
placement?: PlacementOrAuto
116116
}
117+
118+
export const Tooltip = forwardRef(
119+
({ children, content, placement = 'auto' }: TooltipProps, elRef) => {
120+
const {
121+
ref,
122+
props,
123+
Tooltip: TooltipPopup,
124+
} = useTooltip({
125+
content,
126+
placement,
127+
})
128+
129+
let child = Children.only(children)
130+
invariant(child, 'Tooltip must have a single child')
131+
child = cloneElement(child as ReactElement, {
132+
...props,
133+
ref: mergeRefs([ref, elRef]),
134+
})
135+
136+
return (
137+
<>
138+
{child}
139+
<TooltipPopup />
140+
</>
141+
)
142+
}
143+
)

package-lock.json

Lines changed: 15 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
"react-error-boundary": "^3.1.3",
5050
"react-hook-form": "^7.38.0",
5151
"react-is": "^17.0.2",
52+
"react-merge-refs": "^2.0.1",
5253
"react-router-dom": "^6.4.2",
5354
"recharts": "^2.1.12",
5455
"tiny-invariant": "^1.2.0",

0 commit comments

Comments
 (0)