@microbit/ui 0.1.0-alpha.3 → 0.1.0-alpha.31
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/LICENSE.md +40 -0
- package/README.md +279 -26
- package/lang/ui.ar.json +62 -0
- package/lang/ui.ca.json +43 -3
- package/lang/ui.cy.json +62 -0
- package/lang/ui.de.json +62 -0
- package/lang/ui.en-us.json +40 -0
- package/lang/ui.en.json +40 -0
- package/lang/ui.es-es.json +43 -3
- package/lang/ui.fr.json +44 -4
- package/lang/ui.ga-ie.json +62 -0
- package/lang/ui.it.json +62 -0
- package/lang/ui.ja.json +44 -4
- package/lang/ui.ko.json +44 -4
- package/lang/ui.lo.json +62 -0
- package/lang/ui.lol.json +40 -0
- package/lang/ui.nl.json +44 -4
- package/lang/ui.pl.json +44 -4
- package/lang/ui.pt-br.json +44 -4
- package/lang/ui.vi.json +62 -0
- package/lang/ui.zh-cn.json +62 -0
- package/lang/ui.zh-tw.json +44 -4
- package/package.json +19 -10
- package/postcss-legacy-safari.cjs +96 -0
- package/reset.css +35 -0
- package/src/Avatar.recipe.ts +191 -0
- package/src/Avatar.tsx +278 -0
- package/src/Breadcrumb.recipe.ts +45 -0
- package/src/Breadcrumb.tsx +114 -0
- package/src/Button.recipe.ts +88 -50
- package/src/Button.tsx +68 -30
- package/src/ButtonGroup.tsx +37 -15
- package/src/Card.recipe.ts +1 -2
- package/src/Card.tsx +2 -1
- package/src/Checkbox.recipe.ts +49 -14
- package/src/Checkbox.tsx +111 -32
- package/src/CheckboxGroup.tsx +70 -0
- package/src/CloseButton.tsx +3 -3
- package/src/CloseIcon.tsx +6 -4
- package/src/Code.tsx +20 -0
- package/src/Collapse.tsx +179 -0
- package/src/ComboBox.tsx +246 -0
- package/src/Divider.tsx +72 -8
- package/src/Drawer.recipe.ts +21 -10
- package/src/Drawer.tsx +3 -4
- package/src/ExternalLink.tsx +43 -0
- package/src/Fade.tsx +62 -0
- package/src/Field.recipe.ts +114 -0
- package/src/Field.tsx +187 -0
- package/src/GridList.recipe.ts +57 -0
- package/src/GridList.tsx +81 -0
- package/src/Heading.recipe.ts +20 -1
- package/src/Heading.tsx +3 -3
- package/src/Icon.tsx +23 -7
- package/src/IconButton.tsx +8 -13
- package/src/Image.tsx +1 -1
- package/src/Input.recipe.ts +41 -28
- package/src/Input.tsx +23 -7
- package/src/InputGroup.tsx +26 -12
- package/src/Kbd.tsx +26 -0
- package/src/Link.tsx +3 -3
- package/src/LinkBox.tsx +2 -3
- package/src/LinkButton.tsx +81 -0
- package/src/List.tsx +8 -6
- package/src/ListBox.recipe.ts +43 -0
- package/src/ListBox.tsx +88 -0
- package/src/Menu.recipe.ts +51 -17
- package/src/Menu.tsx +117 -2
- package/src/Modal.recipe.ts +17 -9
- package/src/Modal.tsx +137 -24
- package/src/MoreMenuButton.tsx +63 -0
- package/src/NativeSelect.tsx +41 -14
- package/src/NativeSelectField.tsx +84 -0
- package/src/NumberField.recipe.ts +108 -0
- package/src/NumberField.tsx +138 -0
- package/src/PopoverArrow.tsx +19 -5
- package/src/ProgressBar.tsx +3 -5
- package/src/Radio.recipe.ts +108 -0
- package/src/Radio.tsx +62 -0
- package/src/RadioGroup.tsx +68 -0
- package/src/Select.recipe.ts +211 -0
- package/src/Select.tsx +194 -0
- package/src/SharedUIProvider.tsx +63 -7
- package/src/Skeleton.tsx +146 -0
- package/src/Slide.tsx +2 -2
- package/src/Slider.recipe.ts +27 -16
- package/src/Slider.tsx +78 -6
- package/src/Spinner.tsx +11 -9
- package/src/Svg.tsx +2 -3
- package/src/Switch.recipe.ts +57 -11
- package/src/Switch.tsx +39 -7
- package/src/Text.recipe.ts +26 -0
- package/src/Text.tsx +6 -2
- package/src/TextField.tsx +54 -40
- package/src/Toast.recipe.ts +52 -15
- package/src/Toast.tsx +151 -46
- package/src/Tooltip.recipe.ts +32 -0
- package/src/Tooltip.tsx +68 -28
- package/src/TooltipButton.tsx +229 -0
- package/src/UnmountCallback.tsx +2 -2
- package/src/VisuallyHidden.tsx +1 -1
- package/src/base-preset.ts +336 -84
- package/src/{chakra-tokens.ts → base-tokens.ts} +7 -7
- package/src/button-icon.ts +22 -0
- package/src/data-attrs.ts +16 -0
- package/src/dense-preset.ts +107 -0
- package/src/hooks/useBreakpointValue.ts +4 -4
- package/src/hooks/useClipboard.ts +63 -0
- package/src/hooks/useDisclosure.ts +31 -0
- package/src/hooks/useMediaQuery.ts +27 -0
- package/src/hooks/usePrevious.ts +15 -0
- package/src/index.ts +29 -3
- package/src/rac-locale.ts +33 -0
- package/src/system.ts +22 -1
- package/src/TextField.recipe.ts +0 -54
package/src/Checkbox.tsx
CHANGED
|
@@ -3,64 +3,143 @@
|
|
|
3
3
|
*
|
|
4
4
|
* SPDX-License-Identifier: MIT
|
|
5
5
|
*/
|
|
6
|
-
import { ReactNode } from "react";
|
|
6
|
+
import { ReactNode, useId } from "react";
|
|
7
7
|
import {
|
|
8
8
|
Checkbox as RACCheckbox,
|
|
9
9
|
CheckboxProps as RACCheckboxProps,
|
|
10
10
|
} from "react-aria-components";
|
|
11
|
+
import { useIntl } from "react-intl";
|
|
11
12
|
import { css, cx } from "styled-system/css";
|
|
12
|
-
import { checkbox } from "styled-system/recipes";
|
|
13
|
+
import { checkbox, CheckboxVariantProps } from "styled-system/recipes";
|
|
13
14
|
import { SystemStyleObject } from "styled-system/types";
|
|
15
|
+
import { FieldHelperText } from "./Field";
|
|
16
|
+
import { uiMessage } from "./messages";
|
|
17
|
+
|
|
18
|
+
/** What a render-prop child is told about the checkbox. */
|
|
19
|
+
export interface CheckboxState {
|
|
20
|
+
isSelected: boolean;
|
|
21
|
+
isFocusVisible: boolean;
|
|
22
|
+
isDisabled: boolean;
|
|
23
|
+
}
|
|
14
24
|
|
|
15
25
|
export interface CheckboxProps
|
|
16
|
-
extends Omit<RACCheckboxProps, "className" | "children" | "style"
|
|
26
|
+
extends Omit<RACCheckboxProps, "className" | "children" | "style">,
|
|
27
|
+
CheckboxVariantProps {
|
|
17
28
|
/** Per-instance style overrides for the root, merged after the recipe. */
|
|
18
29
|
css?: SystemStyleObject;
|
|
19
30
|
className?: string;
|
|
20
|
-
|
|
31
|
+
/**
|
|
32
|
+
* The label. A function receives the checkbox's state, for a label that
|
|
33
|
+
* changes with it.
|
|
34
|
+
*/
|
|
35
|
+
children?: ReactNode | ((state: CheckboxState) => ReactNode);
|
|
36
|
+
/**
|
|
37
|
+
* Whether to draw the box. `false` is for a checkbox whose children draw
|
|
38
|
+
* the selected state themselves — a selectable tile, or an avatar that
|
|
39
|
+
* grows a tick. The label wrapper goes with it, so the children own the
|
|
40
|
+
* whole row, including the focus ring the box would otherwise carry.
|
|
41
|
+
*
|
|
42
|
+
* @default true
|
|
43
|
+
*/
|
|
44
|
+
control?: boolean;
|
|
45
|
+
/**
|
|
46
|
+
* Help text below the checkbox, wired to its `aria-describedby` — the same
|
|
47
|
+
* chrome the labelled fields' `helperText` renders. With it the component
|
|
48
|
+
* gains a wrapping `<div>`, so the checkbox-plus-text moves as one block.
|
|
49
|
+
*/
|
|
50
|
+
helperText?: ReactNode;
|
|
51
|
+
/** Per-instance style overrides for the helper text. */
|
|
52
|
+
helperTextCss?: SystemStyleObject;
|
|
21
53
|
}
|
|
22
54
|
|
|
23
55
|
/**
|
|
24
|
-
* Checkbox — react-aria-components <Checkbox
|
|
25
|
-
*
|
|
26
|
-
* span for icon-less checkboxes.
|
|
56
|
+
* Checkbox — a styled react-aria-components <Checkbox>. Children render as
|
|
57
|
+
* the label; wrap them in a visually-hidden span for icon-less checkboxes.
|
|
27
58
|
*/
|
|
28
59
|
export const Checkbox = ({
|
|
60
|
+
size,
|
|
29
61
|
css: cssProp,
|
|
30
62
|
className,
|
|
31
63
|
children,
|
|
64
|
+
control,
|
|
65
|
+
helperText,
|
|
66
|
+
helperTextCss,
|
|
32
67
|
...rest
|
|
33
68
|
}: CheckboxProps) => {
|
|
34
|
-
const slots = checkbox();
|
|
35
|
-
|
|
69
|
+
const slots = checkbox({ size });
|
|
70
|
+
const helperId = useId();
|
|
71
|
+
const intl = useIntl();
|
|
72
|
+
const describedBy =
|
|
73
|
+
[rest["aria-describedby"], helperText != null ? helperId : undefined]
|
|
74
|
+
.filter(Boolean)
|
|
75
|
+
.join(" ") || undefined;
|
|
76
|
+
// Default name for a row-selection checkbox (a GridList row's). A future
|
|
77
|
+
// Table's select-all header checkbox shares the slot name and will need a
|
|
78
|
+
// different label.
|
|
79
|
+
const ariaLabel =
|
|
80
|
+
rest["aria-label"] ??
|
|
81
|
+
(rest.slot === "selection"
|
|
82
|
+
? intl.formatMessage(uiMessage("ui.select-row-action"))
|
|
83
|
+
: undefined);
|
|
84
|
+
const checkboxElement = (
|
|
36
85
|
<RACCheckbox
|
|
37
86
|
className={cx(slots.root, cssProp ? css(cssProp) : undefined, className)}
|
|
38
87
|
{...rest}
|
|
88
|
+
aria-label={ariaLabel}
|
|
89
|
+
aria-describedby={describedBy}
|
|
39
90
|
>
|
|
40
|
-
{({ isSelected, isFocusVisible }) =>
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
91
|
+
{({ isSelected, isFocusVisible, isDisabled }) => {
|
|
92
|
+
const content =
|
|
93
|
+
typeof children === "function"
|
|
94
|
+
? children({ isSelected, isFocusVisible, isDisabled })
|
|
95
|
+
: children;
|
|
96
|
+
if (control === false) {
|
|
97
|
+
return content;
|
|
98
|
+
}
|
|
99
|
+
return (
|
|
100
|
+
<>
|
|
101
|
+
<span
|
|
102
|
+
className={slots.control}
|
|
103
|
+
data-selected={isSelected || undefined}
|
|
104
|
+
data-focus-visible={isFocusVisible || undefined}
|
|
105
|
+
data-disabled={isDisabled || undefined}
|
|
106
|
+
aria-hidden
|
|
107
|
+
>
|
|
108
|
+
{isSelected && (
|
|
109
|
+
<svg viewBox="0 0 12 10" className={slots.icon} aria-hidden>
|
|
110
|
+
<polyline
|
|
111
|
+
points="1.5 6 4.5 9 10.5 1"
|
|
112
|
+
fill="none"
|
|
113
|
+
stroke="currentColor"
|
|
114
|
+
strokeWidth="2"
|
|
115
|
+
strokeLinecap="round"
|
|
116
|
+
strokeLinejoin="round"
|
|
117
|
+
/>
|
|
118
|
+
</svg>
|
|
119
|
+
)}
|
|
120
|
+
</span>
|
|
121
|
+
{content != null && (
|
|
122
|
+
<span
|
|
123
|
+
className={slots.label}
|
|
124
|
+
data-disabled={isDisabled || undefined}
|
|
125
|
+
>
|
|
126
|
+
{content}
|
|
127
|
+
</span>
|
|
59
128
|
)}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
)}
|
|
129
|
+
</>
|
|
130
|
+
);
|
|
131
|
+
}}
|
|
64
132
|
</RACCheckbox>
|
|
65
133
|
);
|
|
134
|
+
if (helperText == null) {
|
|
135
|
+
return checkboxElement;
|
|
136
|
+
}
|
|
137
|
+
return (
|
|
138
|
+
<div>
|
|
139
|
+
{checkboxElement}
|
|
140
|
+
<FieldHelperText id={helperId} css={helperTextCss}>
|
|
141
|
+
{helperText}
|
|
142
|
+
</FieldHelperText>
|
|
143
|
+
</div>
|
|
144
|
+
);
|
|
66
145
|
};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* (c) 2026, Micro:bit Educational Foundation and contributors
|
|
3
|
+
*
|
|
4
|
+
* SPDX-License-Identifier: MIT
|
|
5
|
+
*/
|
|
6
|
+
import { ReactNode } from "react";
|
|
7
|
+
import {
|
|
8
|
+
CheckboxGroup as RACCheckboxGroup,
|
|
9
|
+
CheckboxGroupProps as RACCheckboxGroupProps,
|
|
10
|
+
} from "react-aria-components";
|
|
11
|
+
import { css, cx } from "styled-system/css";
|
|
12
|
+
import { SystemStyleObject } from "styled-system/types";
|
|
13
|
+
import { FieldLabel, FieldSupport, FieldSupportProps } from "./Field";
|
|
14
|
+
|
|
15
|
+
export interface CheckboxGroupProps
|
|
16
|
+
extends Omit<RACCheckboxGroupProps, "className" | "style">,
|
|
17
|
+
FieldSupportProps {
|
|
18
|
+
/**
|
|
19
|
+
* Visible label for the group. Use `aria-label` instead where the design
|
|
20
|
+
* has none.
|
|
21
|
+
*/
|
|
22
|
+
label?: ReactNode;
|
|
23
|
+
/** Label style overrides. */
|
|
24
|
+
labelCss?: SystemStyleObject;
|
|
25
|
+
/** Per-instance style overrides. */
|
|
26
|
+
css?: SystemStyleObject;
|
|
27
|
+
className?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* CheckboxGroup — react-aria-components <CheckboxGroup> for a set of
|
|
32
|
+
* Checkboxes sharing one value array (each Checkbox's `value` marks its
|
|
33
|
+
* entry). Beyond the optional field chrome (label/helperText/errorMessage)
|
|
34
|
+
* it carries no styling of its own: compose with Stack for layout, as
|
|
35
|
+
* RadioGroup does.
|
|
36
|
+
*/
|
|
37
|
+
export const CheckboxGroup = ({
|
|
38
|
+
label,
|
|
39
|
+
labelCss,
|
|
40
|
+
helperText,
|
|
41
|
+
errorMessage,
|
|
42
|
+
helperTextCss,
|
|
43
|
+
css: cssProp,
|
|
44
|
+
className,
|
|
45
|
+
children,
|
|
46
|
+
...rest
|
|
47
|
+
}: CheckboxGroupProps) => {
|
|
48
|
+
return (
|
|
49
|
+
<RACCheckboxGroup
|
|
50
|
+
className={cx(cssProp ? css(cssProp) : undefined, className)}
|
|
51
|
+
{...rest}
|
|
52
|
+
>
|
|
53
|
+
{(renderProps) => (
|
|
54
|
+
<>
|
|
55
|
+
{label != null && (
|
|
56
|
+
<FieldLabel isRequired={rest.isRequired} css={labelCss}>
|
|
57
|
+
{label}
|
|
58
|
+
</FieldLabel>
|
|
59
|
+
)}
|
|
60
|
+
{typeof children === "function" ? children(renderProps) : children}
|
|
61
|
+
<FieldSupport
|
|
62
|
+
helperText={helperText}
|
|
63
|
+
errorMessage={errorMessage}
|
|
64
|
+
helperTextCss={helperTextCss}
|
|
65
|
+
/>
|
|
66
|
+
</>
|
|
67
|
+
)}
|
|
68
|
+
</RACCheckboxGroup>
|
|
69
|
+
);
|
|
70
|
+
};
|
package/src/CloseButton.tsx
CHANGED
|
@@ -10,7 +10,7 @@ import { CloseIcon } from "./CloseIcon";
|
|
|
10
10
|
|
|
11
11
|
export interface CloseButtonProps
|
|
12
12
|
extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "className"> {
|
|
13
|
-
/**
|
|
13
|
+
/** sm is a 24px box, md 32px (default). */
|
|
14
14
|
size?: "sm" | "md";
|
|
15
15
|
/**
|
|
16
16
|
* Grow the touch target 8px beyond the visible button on every side via an
|
|
@@ -24,7 +24,7 @@ export interface CloseButtonProps
|
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
/**
|
|
27
|
-
* CloseButton —
|
|
27
|
+
* CloseButton — a standalone X button. A plain button (not react-aria)
|
|
28
28
|
* so call sites can extend the hit area with pseudo-elements, which
|
|
29
29
|
* react-aria's press bounding-rect check would defeat.
|
|
30
30
|
*/
|
|
@@ -57,7 +57,7 @@ export const CloseButton = forwardRef<HTMLButtonElement, CloseButtonProps>(
|
|
|
57
57
|
transitionDuration: "normal",
|
|
58
58
|
_hover: { bg: "blackAlpha.100" },
|
|
59
59
|
_active: { bg: "blackAlpha.200" },
|
|
60
|
-
_focusVisible: {
|
|
60
|
+
_focusVisible: { focusRing: "outline" },
|
|
61
61
|
},
|
|
62
62
|
size === "sm"
|
|
63
63
|
? { width: "6", height: "6", fontSize: "2xs" }
|
package/src/CloseIcon.tsx
CHANGED
|
@@ -12,10 +12,12 @@ export interface CloseIconProps {
|
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
|
-
* CloseIcon — the "✕" glyph used on close buttons (dialogs, toasts).
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
15
|
+
* CloseIcon — the "✕" glyph used on close buttons (dialogs, toasts). Sized to
|
|
16
|
+
* `1em` and `fill: currentColor` like the other icons. Reused wherever a
|
|
17
|
+
* close control is needed.
|
|
18
|
+
*
|
|
19
|
+
* The path is Chakra UI's CloseButton icon, inlined for visual parity with
|
|
20
|
+
* the apps' original look (see the notice in LICENSE.md).
|
|
19
21
|
*/
|
|
20
22
|
export const CloseIcon = ({ css: cssProp, className }: CloseIconProps) => (
|
|
21
23
|
<svg
|
package/src/Code.tsx
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* (c) 2026, Micro:bit Educational Foundation and contributors
|
|
3
|
+
*
|
|
4
|
+
* SPDX-License-Identifier: MIT
|
|
5
|
+
*/
|
|
6
|
+
import { styled } from "styled-system/jsx";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Code — an inline code chip (subtle grey, light mode).
|
|
10
|
+
*/
|
|
11
|
+
export const Code = styled("code", {
|
|
12
|
+
base: {
|
|
13
|
+
fontFamily: "mono",
|
|
14
|
+
fontSize: "sm",
|
|
15
|
+
px: "0.2em",
|
|
16
|
+
borderRadius: "sm",
|
|
17
|
+
bg: "gray.100",
|
|
18
|
+
color: "gray.800",
|
|
19
|
+
},
|
|
20
|
+
});
|
package/src/Collapse.tsx
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* (c) 2026, Micro:bit Educational Foundation and contributors
|
|
3
|
+
*
|
|
4
|
+
* SPDX-License-Identifier: MIT
|
|
5
|
+
*/
|
|
6
|
+
import {
|
|
7
|
+
CSSProperties,
|
|
8
|
+
ReactNode,
|
|
9
|
+
useEffect,
|
|
10
|
+
useLayoutEffect,
|
|
11
|
+
useRef,
|
|
12
|
+
useState,
|
|
13
|
+
} from "react";
|
|
14
|
+
import { css, cx } from "styled-system/css";
|
|
15
|
+
import { SystemStyleObject } from "styled-system/types";
|
|
16
|
+
|
|
17
|
+
export interface CollapseProps {
|
|
18
|
+
/** Expanded when true. */
|
|
19
|
+
isOpen: boolean;
|
|
20
|
+
/** Height when collapsed. */
|
|
21
|
+
startingHeight?: number | string;
|
|
22
|
+
/**
|
|
23
|
+
* Height when expanded (defaults to the measured content height, tracked
|
|
24
|
+
* with a ResizeObserver so nested expansion works).
|
|
25
|
+
*/
|
|
26
|
+
endingHeight?: number | string;
|
|
27
|
+
/** Remove the content from the DOM once the exit transition finishes. */
|
|
28
|
+
unmountOnExit?: boolean;
|
|
29
|
+
css?: SystemStyleObject;
|
|
30
|
+
className?: string;
|
|
31
|
+
style?: CSSProperties;
|
|
32
|
+
children: ReactNode;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const toCssSize = (v: number | string) =>
|
|
36
|
+
typeof v === "number" ? `${v}px` : v;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Collapse — an expand/collapse transition: the content is measured and the
|
|
40
|
+
* wrapper's height (and opacity, when collapsing to zero) transitions between
|
|
41
|
+
* the collapsed and expanded sizes. Content stays mounted unless
|
|
42
|
+
* `unmountOnExit` is set.
|
|
43
|
+
*
|
|
44
|
+
* Only `isOpen` changes animate: initial mounts render at rest, and height
|
|
45
|
+
* corrections (late measurement, content growth) snap — otherwise a collapse
|
|
46
|
+
* mounted inside an entering view morphs during the outer animation.
|
|
47
|
+
*/
|
|
48
|
+
export const Collapse = ({
|
|
49
|
+
isOpen,
|
|
50
|
+
startingHeight = 0,
|
|
51
|
+
endingHeight,
|
|
52
|
+
unmountOnExit = false,
|
|
53
|
+
css: cssProp,
|
|
54
|
+
className,
|
|
55
|
+
style,
|
|
56
|
+
children,
|
|
57
|
+
}: CollapseProps) => {
|
|
58
|
+
const contentRef = useRef<HTMLDivElement>(null);
|
|
59
|
+
const [measuredHeight, setMeasuredHeight] = useState<number>();
|
|
60
|
+
// For unmountOnExit: stay in the DOM until the exit transition ends.
|
|
61
|
+
const [present, setPresent] = useState(isOpen);
|
|
62
|
+
// The state the DOM shows. It follows isOpen from a layout effect so the
|
|
63
|
+
// transition-enable and the height flip land in the same commit (a
|
|
64
|
+
// render-time flip detector is unreliable: React can discard renders,
|
|
65
|
+
// losing the state update while keeping ref mutations).
|
|
66
|
+
const [displayOpen, setDisplayOpen] = useState(isOpen);
|
|
67
|
+
const [animating, setAnimating] = useState(false);
|
|
68
|
+
|
|
69
|
+
useLayoutEffect(() => {
|
|
70
|
+
if (isOpen === displayOpen) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (isOpen) {
|
|
74
|
+
// Opening: mount (collapsed) first, then flip on the next frame so
|
|
75
|
+
// the browser has a computed collapsed state to transition from.
|
|
76
|
+
setPresent(true);
|
|
77
|
+
const raf = requestAnimationFrame(() => {
|
|
78
|
+
setAnimating(true);
|
|
79
|
+
setDisplayOpen(true);
|
|
80
|
+
});
|
|
81
|
+
return () => cancelAnimationFrame(raf);
|
|
82
|
+
}
|
|
83
|
+
// Closing: enable the transition and flip together, pre-paint.
|
|
84
|
+
setAnimating(true);
|
|
85
|
+
setDisplayOpen(false);
|
|
86
|
+
}, [isOpen, displayOpen]);
|
|
87
|
+
|
|
88
|
+
useLayoutEffect(() => {
|
|
89
|
+
const el = contentRef.current;
|
|
90
|
+
if (!el || endingHeight !== undefined) {
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
setMeasuredHeight(el.scrollHeight);
|
|
94
|
+
const observer = new ResizeObserver(() =>
|
|
95
|
+
setMeasuredHeight(el.scrollHeight),
|
|
96
|
+
);
|
|
97
|
+
observer.observe(el);
|
|
98
|
+
return () => observer.disconnect();
|
|
99
|
+
}, [endingHeight, present]);
|
|
100
|
+
|
|
101
|
+
// Fallback for browsers/edge cases where transitionend doesn't fire.
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
if (animating) {
|
|
104
|
+
const timeout = setTimeout(() => {
|
|
105
|
+
setAnimating(false);
|
|
106
|
+
if (!isOpen && unmountOnExit) {
|
|
107
|
+
setPresent(false);
|
|
108
|
+
}
|
|
109
|
+
}, 400);
|
|
110
|
+
return () => clearTimeout(timeout);
|
|
111
|
+
}
|
|
112
|
+
}, [animating, isOpen, unmountOnExit]);
|
|
113
|
+
|
|
114
|
+
if (unmountOnExit && !present) {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const expanded =
|
|
119
|
+
endingHeight !== undefined
|
|
120
|
+
? toCssSize(endingHeight)
|
|
121
|
+
: measuredHeight !== undefined
|
|
122
|
+
? `${measuredHeight}px`
|
|
123
|
+
: "auto";
|
|
124
|
+
const collapsed = toCssSize(startingHeight);
|
|
125
|
+
const hideWhenCollapsed = collapsed === "0px" || collapsed === "0";
|
|
126
|
+
|
|
127
|
+
return (
|
|
128
|
+
<div
|
|
129
|
+
onTransitionEnd={(e) => {
|
|
130
|
+
if (e.propertyName === "height" && e.target === e.currentTarget) {
|
|
131
|
+
setAnimating(false);
|
|
132
|
+
if (!isOpen && unmountOnExit) {
|
|
133
|
+
setPresent(false);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}}
|
|
137
|
+
className={cx(
|
|
138
|
+
css(
|
|
139
|
+
{
|
|
140
|
+
overflow: "hidden",
|
|
141
|
+
display: "block",
|
|
142
|
+
transitionDuration: "0.25s",
|
|
143
|
+
transitionTimingFunction: "cubic-bezier(0.4, 0, 0.2, 1)",
|
|
144
|
+
_motionReduce: { transition: "none" },
|
|
145
|
+
},
|
|
146
|
+
cssProp,
|
|
147
|
+
),
|
|
148
|
+
className,
|
|
149
|
+
)}
|
|
150
|
+
// Runtime-measured/caller-supplied heights; transitions enabled only
|
|
151
|
+
// while an isOpen change is in flight.
|
|
152
|
+
style={{
|
|
153
|
+
transitionProperty: animating ? "height, opacity" : "none",
|
|
154
|
+
height: displayOpen ? expanded : collapsed,
|
|
155
|
+
opacity: displayOpen || !hideWhenCollapsed ? 1 : 0,
|
|
156
|
+
// At rest fully-collapsed, remove the content from the a11y tree
|
|
157
|
+
// and tab order (kept visible while animating so the exit
|
|
158
|
+
// transition shows).
|
|
159
|
+
visibility:
|
|
160
|
+
displayOpen || animating || !hideWhenCollapsed ? undefined : "hidden",
|
|
161
|
+
...style,
|
|
162
|
+
}}
|
|
163
|
+
>
|
|
164
|
+
<div
|
|
165
|
+
ref={contentRef}
|
|
166
|
+
// flow-root: the measured element must be a block formatting
|
|
167
|
+
// context, or children's margins collapse through it and
|
|
168
|
+
// scrollHeight under-measures — the outer (overflow: hidden)
|
|
169
|
+
// wrapper IS a BFC and needs the full height, so the mismatch
|
|
170
|
+
// clips the last line of margined content (python-editor's API
|
|
171
|
+
// docs). Rendering is unchanged: the margins always displayed
|
|
172
|
+
// inside the outer BFC.
|
|
173
|
+
className={css({ display: "flow-root" })}
|
|
174
|
+
>
|
|
175
|
+
{children}
|
|
176
|
+
</div>
|
|
177
|
+
</div>
|
|
178
|
+
);
|
|
179
|
+
};
|