@sanity/ui 5.0.0-alpha.4 → 5.0.0-alpha.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sanity/ui",
3
- "version": "5.0.0-alpha.4",
3
+ "version": "5.0.0-alpha.6",
4
4
  "description": "Sanity UI Library",
5
5
  "keywords": [
6
6
  "components",
@@ -30,11 +30,14 @@
30
30
  ],
31
31
  "type": "module",
32
32
  "sideEffects": [
33
- "*.css"
33
+ "*.css",
34
+ "./src/polyfills.ts",
35
+ "./dist/polyfills.js"
34
36
  ],
35
37
  "types": "./dist/index.d.ts",
36
38
  "exports": {
37
39
  ".": "./dist/index.js",
40
+ "./polyfills": "./dist/polyfills.js",
38
41
  "./package.json": "./package.json",
39
42
  "./styles.css": {
40
43
  "types": "./src/styles.css.d.ts",
@@ -49,6 +52,8 @@
49
52
  "@sanity/icons": "^3.7.4",
50
53
  "clsx": "^2.1.1",
51
54
  "comment-json": "^5.0.0",
55
+ "dialog-closedby-polyfill": "^1.3.0",
56
+ "interestfor": "^1.0.8",
52
57
  "react-refractor": "^4.0.0"
53
58
  },
54
59
  "devDependencies": {
@@ -59,6 +64,7 @@
59
64
  "@types/postcss-import": "^14.0.3",
60
65
  "@types/postcss-prefix-selector": "^1.16.3",
61
66
  "@types/react": "^19.2.17",
67
+ "@types/react-dom": "^19.2.3",
62
68
  "autoprefixer": "^10.4.27",
63
69
  "babel-plugin-react-compiler": "^1.0.0",
64
70
  "concurrently": "^9.2.1",
@@ -29,7 +29,7 @@ export function Checkbox(props: CheckboxProps) {
29
29
 
30
30
  return (
31
31
  <Label
32
- className={clsx(checkboxClassName, className)}
32
+ className={clsx(checkboxClassName, 'sui-position-relative', className)}
33
33
  style={style}
34
34
  data-ui="Checkbox"
35
35
  disabled={disabled}
@@ -15,6 +15,8 @@
15
15
  @import './label/label.css';
16
16
  @import './link/link.css';
17
17
  @import './list/list.css';
18
+ @import './modal/modal.css';
19
+ @import './popover/popover.css';
18
20
  @import './press-area/press-area.css';
19
21
  @import './radio/radio.css';
20
22
  @import './spinner/spinner.css';
@@ -0,0 +1,139 @@
1
+ import {CloseIcon} from '@sanity/icons'
2
+ import clsx from 'clsx'
3
+ import {type ComponentProps, useEffect, useId, useRef} from 'react'
4
+
5
+ import {getProps} from '../../utils/getProps'
6
+ import {suffixClassName} from '../../utils/suffixClassName'
7
+ import {Box} from '../box/Box'
8
+ import {Flex} from '../flex/Flex'
9
+ import {Heading} from '../heading/Heading'
10
+ import {IconButton} from '../icon-button/IconButton'
11
+ import {type ModalProps, modalProps} from './modal.props'
12
+
13
+ const modalClassName = suffixClassName('sui-Modal')
14
+ const modalContentClassName = suffixClassName('sui-ModalContent')
15
+ const modalFooterClassName = suffixClassName('sui-ModalFooter')
16
+
17
+ function ModalRoot({open = false, size = 0, ...props}: ModalProps) {
18
+ const {children, className, style, header, onClose, ...rest} = getProps(
19
+ {size, ...props},
20
+ modalProps,
21
+ )
22
+
23
+ const modalRef = useRef<HTMLDialogElement>(null)
24
+ const headerId = useId()
25
+
26
+ const modalClasses = clsx(
27
+ modalClassName,
28
+ 'sui-inset0 sui-m-auto sui-radius5 sui-shadow3 sui-p4 sui-flex-direction-column sui-gap4 sui-overflow-y-auto',
29
+ )
30
+
31
+ useEffect(() => {
32
+ const dialogElement = modalRef.current
33
+ if (!dialogElement) return
34
+
35
+ // `open` = `open` prop
36
+ // dialogElement.open = the `open` attribute on the HTML dialog element
37
+ // The prop and the attribute are manipulated independently, thus they
38
+ // can get out of sync if not handled within this effect.
39
+ if (open) {
40
+ if (!dialogElement.open) {
41
+ dialogElement.showModal()
42
+ }
43
+ } else {
44
+ if (dialogElement.open) {
45
+ dialogElement.close()
46
+ }
47
+ }
48
+
49
+ // Ensure the modal is closed (and thus its `open` attribute updated)
50
+ // when the component is unmounted.
51
+ return () => {
52
+ dialogElement.close()
53
+ }
54
+ }, [open])
55
+
56
+ return (
57
+ <dialog
58
+ {...rest}
59
+ ref={modalRef}
60
+ closedby="any"
61
+ aria-labelledby={header ? headerId : undefined}
62
+ onClose={onClose}
63
+ className={clsx(modalClasses, className)}
64
+ style={style}
65
+ data-ui="Modal"
66
+ >
67
+ <Flex flexShrink={0} justifyContent="space-between" alignItems="center">
68
+ {header ? (
69
+ <Heading trim size={1} id={headerId}>
70
+ {header}
71
+ </Heading>
72
+ ) : (
73
+ <div />
74
+ )}
75
+ <IconButton
76
+ aria-label="Close"
77
+ level="tertiary"
78
+ icon={CloseIcon}
79
+ onClick={() => modalRef.current?.close()}
80
+ />
81
+ </Flex>
82
+ {children}
83
+ </dialog>
84
+ )
85
+ }
86
+
87
+ function ModalContent(props: ComponentProps<'div'>) {
88
+ const {children, className, style, ...rest} = getProps(props, {})
89
+
90
+ return (
91
+ <Box
92
+ {...rest}
93
+ className={clsx(modalContentClassName, className)}
94
+ style={style}
95
+ data-ui="ModalContent"
96
+ flexGrow={1}
97
+ overflowY="auto"
98
+ >
99
+ {children}
100
+ </Box>
101
+ )
102
+ }
103
+
104
+ function ModalFooter(props: ComponentProps<'div'>) {
105
+ const {children, className, style, ...rest} = getProps(props, {})
106
+
107
+ return (
108
+ <Box
109
+ {...rest}
110
+ className={clsx(modalFooterClassName, className)}
111
+ style={style}
112
+ data-ui="ModalFooter"
113
+ flexShrink={0}
114
+ >
115
+ {children}
116
+ </Box>
117
+ )
118
+ }
119
+
120
+ ModalRoot.displayName = 'Modal'
121
+
122
+ /**
123
+ * @beta
124
+ *
125
+ * Renders a modal dialog element — should not be used for nonmodal dialogs.
126
+ * Modal dialogs make their containing documents inert, and render on the topmost
127
+ * layer within that containing document; they must be dimissed before the containing
128
+ * document and its contents become unblocked. They also trap focus within the bounds
129
+ * of the modal element and its descendants.
130
+ *
131
+ * For more on dialogs and modality, refer to the HTML specification for the dialog element:
132
+ * https://html.spec.whatwg.org/dev/interactive-elements.html#the-dialog-element
133
+ */
134
+ export const Modal = ModalRoot
135
+
136
+ ModalRoot.Content = ModalContent
137
+ ModalRoot.Footer = ModalFooter
138
+
139
+ export type {ModalProps}
@@ -0,0 +1,49 @@
1
+ .sui-Modal {
2
+ /* Computed width is moderated by Modal's size prop, which controls max-width */
3
+ width: calc(100dvw - 2rem);
4
+ max-height: calc(100dvh - 2rem);
5
+ transition:
6
+ display 150ms allow-discrete,
7
+ overlay 150ms allow-discrete,
8
+ opacity 150ms var(--timing-linear),
9
+ scale 150ms var(--timing-curve);
10
+ }
11
+
12
+ .sui-Modal:not([open]) {
13
+ opacity: 0;
14
+ scale: 0.9;
15
+ }
16
+
17
+ .sui-Modal[open] {
18
+ display: flex;
19
+ opacity: 1;
20
+ scale: 1;
21
+ }
22
+
23
+ .sui-Modal::backdrop {
24
+ --modal-backdrop: hsl(from var(--separator-high) h s l / var(--overlay-opacity));
25
+ background-color: var(--modal-backdrop);
26
+ transition:
27
+ display 150ms allow-discrete,
28
+ overlay 150ms allow-discrete,
29
+ opacity 150ms var(--timing-linear);
30
+ }
31
+
32
+ .sui-Modal:not([open])::backdrop {
33
+ opacity: 0;
34
+ }
35
+
36
+ .sui-Modal[open]::backdrop {
37
+ opacity: 1;
38
+ }
39
+
40
+ @starting-style {
41
+ .sui-Modal[open] {
42
+ opacity: 0;
43
+ scale: 0.9;
44
+ }
45
+
46
+ .sui-Modal[open]::backdrop {
47
+ opacity: 0;
48
+ }
49
+ }
@@ -0,0 +1,29 @@
1
+ import {CONTAINER_SIZE, type ContainerSize} from '../../types/Container'
2
+ import {type PropDef} from '../../types/PropDef'
3
+ import type {Responsive} from '../../types/Responsive'
4
+
5
+ /** @beta */
6
+ export interface ModalProps extends Omit<React.ComponentProps<'dialog'>, 'open'> {
7
+ /** Text to be displayed as the modal's heading; optional but recommended for optimal accessibility and presentation */
8
+ header?: string
9
+ /** Used to set the value of open to false. Fires whenever the modal is closed, either programmatically or by the user (via Esc key, clicking background, clicking the modal's close button, or any other means.) */
10
+ onClose: React.ReactEventHandler<HTMLDialogElement>
11
+ /** Whether the modal is open; defaults to false. */
12
+ open?: boolean
13
+ /** Max width of the modal */
14
+ size?: Responsive<ContainerSize>
15
+ }
16
+
17
+ export const modalProps: Record<string, PropDef> = {
18
+ header: {
19
+ type: 'string',
20
+ },
21
+ open: {
22
+ type: 'boolean',
23
+ },
24
+ size: {
25
+ type: 'union',
26
+ className: 'container',
27
+ values: CONTAINER_SIZE,
28
+ },
29
+ }
@@ -0,0 +1,85 @@
1
+ import clsx from 'clsx'
2
+ import {Activity, cloneElement, useId, useState, type ToggleEvent} from 'react'
3
+
4
+ import {useIsClient} from '../../hooks/useIsClient'
5
+ import {getProps} from '../../utils/getProps'
6
+ import {mergeTriggerProps} from '../../utils/mergeTriggerProps'
7
+ import {renderPortal} from '../../utils/renderPortal'
8
+ import {suffixClassName} from '../../utils/suffixClassName'
9
+ import {type PopoverProps, popoverProps} from './popover.props'
10
+
11
+ const popoverClassName = suffixClassName('sui-PopoverContent')
12
+
13
+ function PopoverRoot({
14
+ placement = 'bottom',
15
+ ...props
16
+ }: PopoverProps & {
17
+ triggerProps?: Record<string, unknown>
18
+ }) {
19
+ const {
20
+ children,
21
+ className,
22
+ style,
23
+ id: idProp,
24
+ anchorName,
25
+ content,
26
+ portal,
27
+ triggerProps: forwardedTriggerProps,
28
+ ...rest
29
+ } = getProps({placement, ...props}, popoverProps)
30
+ const reactId = useId()
31
+ const id = idProp || reactId
32
+ const [open, setOpen] = useState(false)
33
+ const isClient = useIsClient()
34
+
35
+ const handleToggle = (e: ToggleEvent) => {
36
+ setOpen(e.newState === 'open')
37
+ }
38
+
39
+ const triggerProps = {
40
+ popoverTarget: id,
41
+ style: {anchorName: `--anchor-${anchorName || id}`},
42
+ }
43
+
44
+ const trigger = children.type.forwardsTriggerProps
45
+ ? cloneElement(children, {triggerProps})
46
+ : cloneElement(children, mergeTriggerProps(children.props, forwardedTriggerProps, triggerProps))
47
+
48
+ return (
49
+ <>
50
+ {trigger}
51
+
52
+ {renderPortal(
53
+ <Activity mode={open ? 'visible' : 'hidden'}>
54
+ <div
55
+ className={clsx(
56
+ popoverClassName,
57
+ 'sui-px2 sui-py1 sui-radius2 sui-position-fixed sui-shadow2',
58
+ className,
59
+ )}
60
+ style={{
61
+ ...style,
62
+ positionAnchor: `--anchor-${anchorName || id}`,
63
+ }}
64
+ data-ui="Popover"
65
+ popover="auto"
66
+ id={id}
67
+ onToggle={handleToggle}
68
+ {...rest}
69
+ >
70
+ {content}
71
+ </div>
72
+ </Activity>,
73
+ isClient,
74
+ portal,
75
+ )}
76
+ </>
77
+ )
78
+ }
79
+
80
+ /** @beta */
81
+ export const Popover = Object.assign(PopoverRoot, {
82
+ forwardsTriggerProps: true,
83
+ })
84
+
85
+ export type {PopoverProps}
@@ -0,0 +1,21 @@
1
+ .sui-PopoverContent {
2
+ visibility: hidden;
3
+ opacity: 0;
4
+ scale: 0.9;
5
+ transition: none;
6
+ }
7
+
8
+ .sui-PopoverContent:popover-open {
9
+ visibility: visible;
10
+ opacity: 1;
11
+ scale: 1;
12
+ transition: var(--fade-in-transition);
13
+ }
14
+
15
+ @starting-style {
16
+ .sui-PopoverContent:popover-open {
17
+ visibility: hidden;
18
+ opacity: 0;
19
+ scale: 0.9;
20
+ }
21
+ }
@@ -0,0 +1,28 @@
1
+ import {type PlacementProps, placementProps} from '../../props/placement'
2
+ import {type PropDef} from '../../types/PropDef'
3
+
4
+ /** @beta */
5
+ export interface PopoverProps
6
+ extends Omit<React.ComponentProps<'div'>, 'children' | 'content'>, PlacementProps {
7
+ /** Anchor name for positioning */
8
+ anchorName?: React.ReactNode
9
+ /** Focusable trigger element */
10
+ children: React.ReactElement<Record<string, unknown>>
11
+ /** Popover content */
12
+ content?: React.ReactNode
13
+ /** Render tooltip in portal */
14
+ portal?: boolean
15
+ }
16
+
17
+ export const popoverProps: Record<string, PropDef> = {
18
+ anchorName: {
19
+ type: 'string',
20
+ },
21
+ content: {
22
+ type: 'string',
23
+ },
24
+ portal: {
25
+ type: 'boolean',
26
+ },
27
+ ...placementProps,
28
+ }
@@ -17,7 +17,7 @@ export function Radio(props: RadioProps) {
17
17
 
18
18
  return (
19
19
  <Label
20
- className={clsx(radioClassName, className)}
20
+ className={clsx(radioClassName, 'sui-position-relative', className)}
21
21
  style={style}
22
22
  data-ui="Radio"
23
23
  disabled={disabled}
@@ -17,7 +17,7 @@ export function Switch(props: SwitchProps) {
17
17
 
18
18
  return (
19
19
  <Label
20
- className={clsx(switchClassName, className)}
20
+ className={clsx(switchClassName, 'sui-position-relative', className)}
21
21
  style={style}
22
22
  data-ui="Switch"
23
23
  disabled={props.disabled}
@@ -1,93 +1,97 @@
1
1
  import clsx from 'clsx'
2
- import {cloneElement, useEffect, useId, useState} from 'react'
2
+ import {cloneElement, useId, useState, type ToggleEvent} from 'react'
3
3
 
4
+ import {useIsClient} from '../../hooks/useIsClient'
4
5
  import {getProps} from '../../utils/getProps'
6
+ import {mergeTriggerProps} from '../../utils/mergeTriggerProps'
7
+ import {renderPortal} from '../../utils/renderPortal'
5
8
  import {suffixClassName} from '../../utils/suffixClassName'
6
- import {Box} from '../box/Box'
7
9
  import {type TooltipProps, tooltipProps} from './tooltip.props'
8
10
 
9
11
  const tooltipClassName = suffixClassName('sui-Tooltip')
10
- const tooltipDismissedClassName = suffixClassName('sui-Tooltip-Dismissed')
11
12
 
12
- /** @public */
13
- export function Tooltip({placement = 'bottom', ...props}: TooltipProps) {
13
+ function TooltipRoot({
14
+ placement = 'bottom',
15
+ ...props
16
+ }: TooltipProps & {
17
+ triggerProps?: Record<string, unknown>
18
+ }) {
14
19
  const {
15
20
  children,
16
21
  className,
17
22
  style,
18
- disabled,
19
23
  id: idProp,
20
- text,
24
+ anchorName,
25
+ content,
26
+ portal,
27
+ triggerProps: forwardedTriggerProps,
21
28
  ...rest
22
29
  } = getProps({placement, ...props}, tooltipProps)
23
30
  const reactId = useId()
24
31
  const id = idProp || reactId
25
32
  const [dismissed, setDismissed] = useState(false)
33
+ const isClient = useIsClient()
26
34
 
27
- useEffect(() => {
28
- if (dismissed) {
29
- return
30
- }
31
-
32
- const handleKeyDown = (e: KeyboardEvent) => {
33
- if (e.key === 'Escape') {
34
- setDismissed(true)
35
- }
36
- }
37
-
38
- window.addEventListener('keydown', handleKeyDown)
39
- return () => window.removeEventListener('keydown', handleKeyDown)
40
- }, [dismissed])
41
-
42
- const trigger = cloneElement(children, {
35
+ const triggerProps = {
43
36
  'aria-describedby': id,
44
- 'onMouseEnter': (e) => {
37
+ 'interestfor': id,
38
+ 'style': {anchorName: `--anchor-${anchorName || id}`},
39
+ 'onMouseLeave': () => {
45
40
  setDismissed(false)
46
- children.props.onMouseEnter?.(e)
47
41
  },
48
- 'onFocus': (e) => {
42
+ 'onBlur': () => {
49
43
  setDismissed(false)
50
- children.props.onFocus?.(e)
51
44
  },
52
- 'onClick': (e) => {
45
+ 'onClick': () => {
53
46
  setDismissed(true)
54
- children.props.onClick?.(e)
47
+ document.getElementById(id)?.hidePopover()
55
48
  },
56
- 'style': {
57
- ...children.props.style,
58
- anchorName: `--tooltip-anchor-${id}`,
59
- },
60
- })
49
+ }
50
+
51
+ const trigger = children.type.forwardsTriggerProps
52
+ ? cloneElement(children, {triggerProps})
53
+ : cloneElement(children, mergeTriggerProps(children.props, forwardedTriggerProps, triggerProps))
61
54
 
62
- if (disabled) {
63
- return children
55
+ const handleBeforeToggle = (e: ToggleEvent) => {
56
+ if (e.newState === 'open' && dismissed) {
57
+ e.preventDefault()
58
+ }
64
59
  }
65
60
 
66
61
  return (
67
62
  <>
68
63
  {trigger}
69
64
 
70
- <Box
71
- className={clsx(tooltipClassName, dismissed ? tooltipDismissedClassName : '', className)}
72
- role="tooltip"
73
- style={{
74
- ...style,
75
- positionAnchor: `--tooltip-anchor-${id}`,
76
- }}
77
- data-ui="Tooltip"
78
- id={id}
79
- paddingX={2}
80
- paddingY={1}
81
- radius={2}
82
- position="fixed"
83
- zIndex={9999}
84
- shadow={2}
85
- {...rest}
86
- >
87
- {text}
88
- </Box>
65
+ {renderPortal(
66
+ <div
67
+ className={clsx(
68
+ tooltipClassName,
69
+ 'sui-px2 sui-py1 sui-radius2 sui-position-fixed sui-shadow2',
70
+ className,
71
+ )}
72
+ style={{
73
+ ...style,
74
+ positionAnchor: `--anchor-${anchorName || id}`,
75
+ }}
76
+ data-ui="Tooltip"
77
+ role="tooltip"
78
+ popover="hint"
79
+ id={id}
80
+ onBeforeToggle={handleBeforeToggle}
81
+ {...rest}
82
+ >
83
+ {content}
84
+ </div>,
85
+ isClient,
86
+ portal,
87
+ )}
89
88
  </>
90
89
  )
91
90
  }
92
91
 
92
+ /** @beta */
93
+ export const Tooltip = Object.assign(TooltipRoot, {
94
+ forwardsTriggerProps: true,
95
+ }) as typeof TooltipRoot
96
+
93
97
  export type {TooltipProps}
@@ -1,52 +1,31 @@
1
+ [interestfor] {
2
+ --tooltip-delay: var(--tooltip-delay-group, 500ms);
3
+ /* The interestfor polyfill looks for intereset delay variables */
4
+ --interest-delay-start: var(--tooltip-delay);
5
+ --interest-delay-end: 0ms;
6
+ interest-delay-start: var(--tooltip-delay);
7
+ interest-delay-end: 0ms;
8
+ }
9
+
1
10
  .sui-Tooltip {
2
- --tooltip-delay-open: var(--tooltip-delay-group, 500ms);
3
- --tooltip-delay-close: var(--tooltip-delay-group, 100ms);
4
11
  background: var(--backdrop);
12
+ visibility: hidden;
5
13
  opacity: 0;
6
14
  scale: 0.9;
7
- visibility: hidden;
8
- transition:
9
- opacity 200ms cubic-bezier(0.34, 1.2, 0.64, 1) var(--tooltip-delay-close),
10
- scale 200ms cubic-bezier(0.34, 1.2, 0.64, 1) var(--tooltip-delay-close),
11
- visibility 0s linear calc(var(--tooltip-delay-close) + 200ms);
15
+ transition: none;
12
16
  }
13
17
 
14
- [aria-describedby]:is(:hover, :focus-visible) + [role="tooltip"]:not(.sui-Tooltip-Dismissed) {
18
+ .sui-Tooltip:popover-open {
15
19
  visibility: visible;
16
20
  opacity: 1;
17
21
  scale: 1;
18
- transition:
19
- opacity 200ms cubic-bezier(0.34, 1.2, 0.64, 1) var(--tooltip-delay-open),
20
- scale 200ms cubic-bezier(0.34, 1.2, 0.64, 1) var(--tooltip-delay-open),
21
- visibility 0s linear var(--tooltip-delay-open);
22
- }
23
-
24
- [aria-describedby]:is(:hover, :focus-visible) + .sui-Tooltip-Dismissed[role="tooltip"] {
25
- transition: none;
26
- }
27
-
28
- .sui-Tooltip[class*='placement-bottom'],
29
- .sui-Tooltip[class*='placement-top'] {
30
- margin-block: var(--space-1);
31
- }
32
-
33
- .sui-Tooltip[class*='placement-left'],
34
- .sui-Tooltip[class*='placement-right'] {
35
- margin-inline: var(--space-1);
36
- }
37
-
38
- .sui-Tooltip[class*='bottom'] {
39
- transform-origin: center top;
40
- }
41
-
42
- .sui-Tooltip[class*='top'] {
43
- transform-origin: center bottom;
44
- }
45
-
46
- .sui-Tooltip[class*='left'] {
47
- transform-origin: right center;
22
+ transition: var(--fade-in-transition);
48
23
  }
49
24
 
50
- .sui-Tooltip[class*='right'] {
51
- transform-origin: left center;
25
+ @starting-style {
26
+ .sui-Tooltip:popover-open {
27
+ visibility: hidden;
28
+ opacity: 0;
29
+ scale: 0.9;
30
+ }
52
31
  }