@structuralists/scaffolding 0.0.1
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/.storybook/main.ts +9 -0
- package/.storybook/manager.ts +13 -0
- package/.storybook/preview.tsx +18 -0
- package/CLAUDE.md +30 -0
- package/LICENSE +21 -0
- package/README.md +7 -0
- package/bun.lock +947 -0
- package/bunfig.toml +2 -0
- package/eslint.config.mjs +106 -0
- package/index.ts +1 -0
- package/package.json +50 -0
- package/src/components/Chat/ChatComposer/ChatComposer.stories.tsx +68 -0
- package/src/components/Chat/ChatComposer/index.tsx +74 -0
- package/src/components/Chat/ChatComposer/styles.module.css +88 -0
- package/src/components/Chat/ChatComposer/types.ts +11 -0
- package/src/components/Chat/ChatMessage/ChatMessage.stories.tsx +111 -0
- package/src/components/Chat/ChatMessage/index.tsx +42 -0
- package/src/components/Chat/ChatMessage/styles.module.css +58 -0
- package/src/components/Chat/ChatMessage/types.ts +14 -0
- package/src/components/Chat/ChatRecipientsHeader/ChatRecipientsHeader.stories.tsx +145 -0
- package/src/components/Chat/ChatRecipientsHeader/index.tsx +29 -0
- package/src/components/Chat/ChatRecipientsHeader/styles.module.css +48 -0
- package/src/components/Chat/ChatRecipientsHeader/types.ts +26 -0
- package/src/components/Chat/ChatShell/ChatShell.stories.tsx +203 -0
- package/src/components/Chat/ChatShell/index.tsx +16 -0
- package/src/components/Chat/ChatShell/styles.module.css +27 -0
- package/src/components/Chat/ChatShell/types.ts +7 -0
- package/src/components/Chat/PillCombobox/PillCombobox.stories.tsx +59 -0
- package/src/components/Chat/PillCombobox/index.tsx +17 -0
- package/src/components/Chat/PillCombobox/styles.module.css +29 -0
- package/src/components/Chat/PillCombobox/types.ts +28 -0
- package/src/components/Chat/PillComboboxCore/Core.tsx +235 -0
- package/src/components/Chat/PillComboboxCore/styles.module.css +79 -0
- package/src/components/Chat/index.ts +12 -0
- package/src/components/Content/Badge/Badge.stories.tsx +31 -0
- package/src/components/Content/Badge/index.tsx +22 -0
- package/src/components/Content/Badge/styles.module.css +25 -0
- package/src/components/Content/Badge/types.ts +7 -0
- package/src/components/Content/Card/Card.stories.tsx +24 -0
- package/src/components/Content/Card/index.tsx +21 -0
- package/src/components/Content/Card/styles.module.css +13 -0
- package/src/components/Content/Card/types.ts +8 -0
- package/src/components/Content/EditableMarkdown/EditableMarkdown.stories.tsx +58 -0
- package/src/components/Content/EditableMarkdown/index.tsx +140 -0
- package/src/components/Content/EditableMarkdown/styles.module.css +221 -0
- package/src/components/Content/EditableMarkdown/types.ts +11 -0
- package/src/components/Content/Heading/Heading.stories.tsx +26 -0
- package/src/components/Content/Heading/index.tsx +20 -0
- package/src/components/Content/Heading/styles.module.css +19 -0
- package/src/components/Content/Heading/types.ts +8 -0
- package/src/components/Content/Link/Link.stories.tsx +21 -0
- package/src/components/Content/Link/index.tsx +19 -0
- package/src/components/Content/Link/styles.module.css +11 -0
- package/src/components/Content/Link/types.ts +8 -0
- package/src/components/Content/List/List.stories.tsx +62 -0
- package/src/components/Content/List/index.tsx +26 -0
- package/src/components/Content/List/styles.module.css +41 -0
- package/src/components/Content/List/types.ts +33 -0
- package/src/components/Content/LoadingContainer/LoadingContainer.stories.tsx +105 -0
- package/src/components/Content/LoadingContainer/index.tsx +36 -0
- package/src/components/Content/LoadingContainer/styles.module.css +54 -0
- package/src/components/Content/LoadingContainer/types.ts +8 -0
- package/src/components/Content/Markdown/Markdown.stories.tsx +39 -0
- package/src/components/Content/Markdown/index.tsx +28 -0
- package/src/components/Content/Markdown/styles.module.css +79 -0
- package/src/components/Content/Markdown/types.ts +8 -0
- package/src/components/Content/Menu/Menu.stories.tsx +186 -0
- package/src/components/Content/Menu/index.tsx +259 -0
- package/src/components/Content/Menu/styles.module.css +103 -0
- package/src/components/Content/Menu/types.ts +25 -0
- package/src/components/Content/Text/Text.stories.tsx +36 -0
- package/src/components/Content/Text/index.tsx +35 -0
- package/src/components/Content/Text/styles.module.css +30 -0
- package/src/components/Content/Text/types.ts +11 -0
- package/src/components/Forms/Button/Button.stories.tsx +40 -0
- package/src/components/Forms/Button/index.tsx +43 -0
- package/src/components/Forms/Button/styles.module.css +67 -0
- package/src/components/Forms/Button/types.ts +16 -0
- package/src/components/Forms/ColorInput/index.tsx +22 -0
- package/src/components/Forms/ColorInput/styles.module.css +19 -0
- package/src/components/Forms/ColorInput/types.ts +12 -0
- package/src/components/Forms/Field/Field.stories.tsx +35 -0
- package/src/components/Forms/Field/index.tsx +17 -0
- package/src/components/Forms/Field/styles.module.css +21 -0
- package/src/components/Forms/Field/types.ts +9 -0
- package/src/components/Forms/IconButton/IconButton.stories.tsx +91 -0
- package/src/components/Forms/IconButton/index.tsx +55 -0
- package/src/components/Forms/IconButton/styles.module.css +61 -0
- package/src/components/Forms/IconButton/types.ts +23 -0
- package/src/components/Forms/Input/Input.stories.tsx +22 -0
- package/src/components/Forms/Input/index.tsx +42 -0
- package/src/components/Forms/Input/styles.module.css +30 -0
- package/src/components/Forms/Input/types.ts +18 -0
- package/src/components/Forms/SearchInput/index.tsx +41 -0
- package/src/components/Forms/SearchInput/styles.module.css +30 -0
- package/src/components/Forms/SearchInput/types.ts +17 -0
- package/src/components/Forms/Select/MultiSelect/MultiSelect.stories.tsx +116 -0
- package/src/components/Forms/Select/MultiSelect/index.tsx +74 -0
- package/src/components/Forms/Select/MultiSelect/types.ts +15 -0
- package/src/components/Forms/Select/SingleSelect/SingleSelect.stories.tsx +174 -0
- package/src/components/Forms/Select/SingleSelect/index.tsx +62 -0
- package/src/components/Forms/Select/SingleSelect/types.ts +12 -0
- package/src/components/Forms/Select/index.ts +4 -0
- package/src/components/Forms/Select/internal/OptionList.tsx +124 -0
- package/src/components/Forms/Select/internal/SelectTrigger.tsx +60 -0
- package/src/components/Forms/Select/internal/styles.module.css +122 -0
- package/src/components/Forms/Textarea/Textarea.stories.tsx +25 -0
- package/src/components/Forms/Textarea/index.tsx +48 -0
- package/src/components/Forms/Textarea/styles.module.css +34 -0
- package/src/components/Forms/Textarea/types.ts +24 -0
- package/src/components/Json/Json/Json.stories.tsx +33 -0
- package/src/components/Json/Json/index.tsx +38 -0
- package/src/components/Json/Json/types.ts +21 -0
- package/src/components/Json/JsonTable/JsonLeafNode.tsx +31 -0
- package/src/components/Json/JsonTable/JsonTable.stories.tsx +52 -0
- package/src/components/Json/JsonTable/index.tsx +33 -0
- package/src/components/Json/JsonTable/types.ts +13 -0
- package/src/components/Json/JsonTable/utils.ts +6 -0
- package/src/components/Layout/Bar/Bar.stories.tsx +100 -0
- package/src/components/Layout/Bar/index.tsx +17 -0
- package/src/components/Layout/Bar/styles.module.css +34 -0
- package/src/components/Layout/Bar/types.ts +10 -0
- package/src/components/Layout/Debug/Debug.stories.tsx +86 -0
- package/src/components/Layout/Debug/index.tsx +41 -0
- package/src/components/Layout/Debug/styles.module.css +13 -0
- package/src/components/Layout/Debug/types.ts +12 -0
- package/src/components/Layout/Divider/Divider.stories.tsx +22 -0
- package/src/components/Layout/Divider/index.tsx +3 -0
- package/src/components/Layout/Divider/styles.module.css +6 -0
- package/src/components/Layout/Grid/Grid.stories.tsx +28 -0
- package/src/components/Layout/Grid/index.tsx +29 -0
- package/src/components/Layout/Grid/styles.module.css +12 -0
- package/src/components/Layout/Grid/types.ts +9 -0
- package/src/components/Layout/Panels/Panels.stories.tsx +287 -0
- package/src/components/Layout/Panels/index.tsx +119 -0
- package/src/components/Layout/Panels/styles.module.css +103 -0
- package/src/components/Layout/Panels/types.ts +36 -0
- package/src/components/Layout/Stack/Stack.stories.tsx +45 -0
- package/src/components/Layout/Stack/index.tsx +73 -0
- package/src/components/Layout/Stack/styles.module.css +41 -0
- package/src/components/Layout/Stack/types.ts +17 -0
- package/src/components/Modals/ConfirmModal/ConfirmModal.stories.tsx +73 -0
- package/src/components/Modals/ConfirmModal/index.tsx +72 -0
- package/src/components/Modals/ConfirmModal/styles.module.css +62 -0
- package/src/components/Modals/ConfirmModal/types.ts +14 -0
- package/src/components/Modals/LargeModal/LargeModal.stories.tsx +75 -0
- package/src/components/Modals/LargeModal/index.tsx +9 -0
- package/src/components/Modals/LargeModal/styles.module.css +6 -0
- package/src/components/Modals/LargeModal/types.ts +18 -0
- package/src/components/Modals/MediumModal/MediumModal.stories.tsx +121 -0
- package/src/components/Modals/MediumModal/MediumModal.test.tsx +48 -0
- package/src/components/Modals/MediumModal/index.tsx +9 -0
- package/src/components/Modals/MediumModal/styles.module.css +5 -0
- package/src/components/Modals/MediumModal/types.ts +18 -0
- package/src/components/Modals/index.ts +3 -0
- package/src/components/Modals/internal/ModalBody.tsx +21 -0
- package/src/components/Modals/internal/ModalFooter.tsx +12 -0
- package/src/components/Modals/internal/ModalHeader.tsx +27 -0
- package/src/components/Modals/internal/ModalShell.tsx +112 -0
- package/src/components/Modals/internal/styles.module.css +141 -0
- package/src/components/Navigation/TabBar/TabBar.stories.tsx +59 -0
- package/src/components/Navigation/TabBar/index.tsx +25 -0
- package/src/components/Navigation/TabBar/styles.module.css +32 -0
- package/src/components/Navigation/TabBar/types.ts +22 -0
- package/src/components/Navigation/VerticalNav/VerticalNav.stories.tsx +41 -0
- package/src/components/Navigation/VerticalNav/index.tsx +25 -0
- package/src/components/Navigation/VerticalNav/styles.module.css +28 -0
- package/src/components/Navigation/VerticalNav/types.ts +19 -0
- package/src/components/Overlays/Popover/Popover.stories.tsx +154 -0
- package/src/components/Overlays/Popover/index.tsx +175 -0
- package/src/components/Overlays/Popover/styles.module.css +59 -0
- package/src/components/Overlays/Popover/types.ts +34 -0
- package/src/components/Overlays/Tooltip/Tooltip.stories.tsx +41 -0
- package/src/components/Overlays/Tooltip/index.tsx +115 -0
- package/src/components/Overlays/Tooltip/styles.module.css +25 -0
- package/src/components/Overlays/Tooltip/types.ts +15 -0
- package/src/components/Primitives/EmptyValue/EmptyValue.stories.tsx +18 -0
- package/src/components/Primitives/EmptyValue/index.tsx +3 -0
- package/src/components/Primitives/EmptyValue/styles.module.css +3 -0
- package/src/components/Primitives/LinedStack/LinedStack.stories.tsx +101 -0
- package/src/components/Primitives/LinedStack/index.tsx +41 -0
- package/src/components/Primitives/LinedStack/styles.module.css +27 -0
- package/src/components/Primitives/LinedStack/types.ts +49 -0
- package/src/components/Primitives/LongText/LongText.stories.tsx +72 -0
- package/src/components/Primitives/LongText/index.tsx +67 -0
- package/src/components/Primitives/LongText/styles.module.css +30 -0
- package/src/components/Primitives/LongText/types.ts +4 -0
- package/src/components/Primitives/Num/Num.stories.tsx +51 -0
- package/src/components/Primitives/Num/index.tsx +37 -0
- package/src/components/Primitives/Num/types.ts +19 -0
- package/src/components/Primitives/Percent/Percent.stories.tsx +48 -0
- package/src/components/Primitives/Percent/index.tsx +15 -0
- package/src/components/Primitives/Percent/types.ts +10 -0
- package/src/components/Primitives/RelativeTime/RelativeTime.stories.tsx +57 -0
- package/src/components/Primitives/RelativeTime/index.tsx +31 -0
- package/src/components/Primitives/RelativeTime/types.ts +3 -0
- package/src/components/Tables/BigTable/BigTable.stories.tsx +367 -0
- package/src/components/Tables/BigTable/CLAUDE.md +118 -0
- package/src/components/Tables/BigTable/columnDefs.tsx +208 -0
- package/src/components/Tables/BigTable/index.tsx +104 -0
- package/src/components/Tables/BigTable/styles.module.css +83 -0
- package/src/components/Tables/BigTable/types.ts +20 -0
- package/src/components/Tables/QuickTable/CLAUDE.md +118 -0
- package/src/components/Tables/QuickTable/QuickTable.stories.tsx +121 -0
- package/src/components/Tables/QuickTable/index.tsx +86 -0
- package/src/components/Tables/QuickTable/internal.tsx +48 -0
- package/src/components/Tables/QuickTable/styles.module.css +65 -0
- package/src/components/Tables/QuickTable/types.ts +40 -0
- package/src/env.d.ts +4 -0
- package/src/index.ts +87 -0
- package/src/storybook/CLAUDE.md +35 -0
- package/src/storybook/Composition.stories.tsx +269 -0
- package/src/storybook/Lorem/index.tsx +54 -0
- package/src/storybook/Placeholder/index.tsx +27 -0
- package/src/storybook/Placeholder/styles.module.css +20 -0
- package/src/storybook/Repeat/index.tsx +23 -0
- package/src/storybook/Toggle/index.tsx +29 -0
- package/src/storybook/_StoryUtils.stories.tsx +58 -0
- package/src/storybook/index.ts +4 -0
- package/src/tokens.ts +31 -0
- package/src/utils.test.ts +24 -0
- package/src/utils.ts +2 -0
- package/test-setup.ts +3 -0
- package/tokens.css +323 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useEffect,
|
|
3
|
+
useId,
|
|
4
|
+
useLayoutEffect,
|
|
5
|
+
useRef,
|
|
6
|
+
useState,
|
|
7
|
+
type CSSProperties,
|
|
8
|
+
} from 'react';
|
|
9
|
+
import { createPortal } from 'react-dom';
|
|
10
|
+
import type { PopoverPlacement, PopoverProps } from './types';
|
|
11
|
+
import { cx } from '../../../utils';
|
|
12
|
+
import styles from './styles.module.css';
|
|
13
|
+
|
|
14
|
+
const GAP = 6;
|
|
15
|
+
|
|
16
|
+
type Position = {
|
|
17
|
+
top: number;
|
|
18
|
+
left: number;
|
|
19
|
+
/** Side of the panel that's closest to the anchor — used as transform-origin
|
|
20
|
+
* so the open animation grows *from* the trigger, not from panel center. */
|
|
21
|
+
originV: 'top' | 'bottom';
|
|
22
|
+
originH: 'left' | 'right';
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const computePosition = (
|
|
26
|
+
anchor: DOMRect,
|
|
27
|
+
panel: DOMRect,
|
|
28
|
+
preferred: PopoverPlacement,
|
|
29
|
+
): Position => {
|
|
30
|
+
const winW = window.innerWidth;
|
|
31
|
+
const winH = window.innerHeight;
|
|
32
|
+
|
|
33
|
+
const preferBottom =
|
|
34
|
+
preferred === 'auto'
|
|
35
|
+
? winH - anchor.bottom >= panel.height + GAP ||
|
|
36
|
+
winH - anchor.bottom >= anchor.top
|
|
37
|
+
: preferred.startsWith('bottom');
|
|
38
|
+
|
|
39
|
+
const preferStart =
|
|
40
|
+
preferred === 'auto'
|
|
41
|
+
? anchor.left + panel.width <= winW
|
|
42
|
+
: preferred.endsWith('-start');
|
|
43
|
+
|
|
44
|
+
const top = preferBottom
|
|
45
|
+
? anchor.bottom + GAP
|
|
46
|
+
: anchor.top - panel.height - GAP;
|
|
47
|
+
const left = preferStart ? anchor.left : anchor.right - panel.width;
|
|
48
|
+
|
|
49
|
+
// 8px viewport margin keeps a near-edge anchor from pushing the panel off.
|
|
50
|
+
const clampedLeft = Math.max(8, Math.min(left, winW - panel.width - 8));
|
|
51
|
+
const clampedTop = Math.max(8, Math.min(top, winH - panel.height - 8));
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
top: clampedTop,
|
|
55
|
+
left: clampedLeft,
|
|
56
|
+
originV: preferBottom ? 'top' : 'bottom',
|
|
57
|
+
originH: preferStart ? 'left' : 'right',
|
|
58
|
+
};
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export const Popover = (props: PopoverProps) => {
|
|
62
|
+
const {
|
|
63
|
+
anchorRef,
|
|
64
|
+
isOpen,
|
|
65
|
+
onClose,
|
|
66
|
+
title,
|
|
67
|
+
actions,
|
|
68
|
+
ariaLabel,
|
|
69
|
+
placement = 'auto',
|
|
70
|
+
matchAnchorWidth,
|
|
71
|
+
hasBodyPadding = true,
|
|
72
|
+
children,
|
|
73
|
+
} = props;
|
|
74
|
+
|
|
75
|
+
const panelRef = useRef<HTMLDivElement>(null);
|
|
76
|
+
const restoreFocusRef = useRef<HTMLElement | null>(null);
|
|
77
|
+
const [position, setPosition] = useState<Position | null>(null);
|
|
78
|
+
const titleId = useId();
|
|
79
|
+
|
|
80
|
+
useLayoutEffect(() => {
|
|
81
|
+
if (!isOpen) {
|
|
82
|
+
setPosition(null);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const anchor = anchorRef.current;
|
|
86
|
+
const panel = panelRef.current;
|
|
87
|
+
if (!anchor || !panel) return;
|
|
88
|
+
|
|
89
|
+
const anchorRect = anchor.getBoundingClientRect();
|
|
90
|
+
|
|
91
|
+
// Apply the anchor width *before* measuring the panel rect so the panel's
|
|
92
|
+
// height reflects the final width (content may wrap differently).
|
|
93
|
+
if (matchAnchorWidth) {
|
|
94
|
+
panel.style.width = `${anchorRect.width}px`;
|
|
95
|
+
panel.style.minWidth = `${anchorRect.width}px`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
setPosition(
|
|
99
|
+
computePosition(anchorRect, panel.getBoundingClientRect(), placement),
|
|
100
|
+
);
|
|
101
|
+
}, [isOpen, anchorRef, placement, matchAnchorWidth]);
|
|
102
|
+
|
|
103
|
+
useEffect(() => {
|
|
104
|
+
if (!isOpen) return;
|
|
105
|
+
|
|
106
|
+
const handleMouseDown = (e: MouseEvent) => {
|
|
107
|
+
const target = e.target as Node;
|
|
108
|
+
if (panelRef.current?.contains(target)) return;
|
|
109
|
+
if (anchorRef.current?.contains(target)) return;
|
|
110
|
+
onClose();
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const handleKey = (e: KeyboardEvent) => {
|
|
114
|
+
if (e.key === 'Escape') onClose();
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
window.addEventListener('mousedown', handleMouseDown);
|
|
118
|
+
window.addEventListener('keydown', handleKey);
|
|
119
|
+
return () => {
|
|
120
|
+
window.removeEventListener('mousedown', handleMouseDown);
|
|
121
|
+
window.removeEventListener('keydown', handleKey);
|
|
122
|
+
};
|
|
123
|
+
}, [isOpen, anchorRef, onClose]);
|
|
124
|
+
|
|
125
|
+
// Capture the anchor at open time so a rerender that swaps the element
|
|
126
|
+
// still restores focus to whatever the user originally engaged.
|
|
127
|
+
useEffect(() => {
|
|
128
|
+
if (isOpen) {
|
|
129
|
+
restoreFocusRef.current = anchorRef.current;
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (restoreFocusRef.current) {
|
|
133
|
+
restoreFocusRef.current.focus();
|
|
134
|
+
restoreFocusRef.current = null;
|
|
135
|
+
}
|
|
136
|
+
}, [isOpen, anchorRef]);
|
|
137
|
+
|
|
138
|
+
if (!isOpen) return null;
|
|
139
|
+
|
|
140
|
+
const style: CSSProperties = position
|
|
141
|
+
? {
|
|
142
|
+
top: position.top,
|
|
143
|
+
left: position.left,
|
|
144
|
+
transformOrigin: `${position.originV} ${position.originH}`,
|
|
145
|
+
}
|
|
146
|
+
: { top: 0, left: 0, visibility: 'hidden' };
|
|
147
|
+
|
|
148
|
+
// Portal to <body> so we sit at the same DOM depth as every other layer
|
|
149
|
+
// (modal, future tooltip/menu when they get layered). Stacking falls out
|
|
150
|
+
// of mount order — the most-recently-opened layer is the last child of
|
|
151
|
+
// <body> and paints on top, no z-index tier required.
|
|
152
|
+
return createPortal(
|
|
153
|
+
<div
|
|
154
|
+
ref={panelRef}
|
|
155
|
+
className={styles.panel}
|
|
156
|
+
role="dialog"
|
|
157
|
+
aria-labelledby={title ? titleId : undefined}
|
|
158
|
+
aria-label={!title ? ariaLabel : undefined}
|
|
159
|
+
style={style}
|
|
160
|
+
>
|
|
161
|
+
{title && (
|
|
162
|
+
<div id={titleId} className={styles.title}>
|
|
163
|
+
{title}
|
|
164
|
+
</div>
|
|
165
|
+
)}
|
|
166
|
+
<div className={cx(styles.body, !hasBodyPadding && styles.bodyFlush)}>
|
|
167
|
+
{children}
|
|
168
|
+
</div>
|
|
169
|
+
{actions && <div className={styles.actions}>{actions}</div>}
|
|
170
|
+
</div>,
|
|
171
|
+
document.body,
|
|
172
|
+
);
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
export type { PopoverProps, PopoverPlacement };
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
.panel {
|
|
2
|
+
position: fixed;
|
|
3
|
+
/* Same z-index as ModalShell's .layer — all portal layers share one tier
|
|
4
|
+
* and rely on DOM-append order for stacking. The most-recently-opened
|
|
5
|
+
* layer is the last child of <body> and paints on top. */
|
|
6
|
+
z-index: 100;
|
|
7
|
+
min-width: 18rem;
|
|
8
|
+
max-width: min(480px, calc(100vw - 32px));
|
|
9
|
+
background-color: var(--ui-background-0);
|
|
10
|
+
border: 1px solid var(--ui-border);
|
|
11
|
+
border-radius: var(--ui-radius);
|
|
12
|
+
box-shadow: 0 4px 16px var(--ui-shadow-soft);
|
|
13
|
+
display: flex;
|
|
14
|
+
flex-direction: column;
|
|
15
|
+
/* Clip the bg-1 title/actions strips to the panel's rounded corners. */
|
|
16
|
+
overflow: hidden;
|
|
17
|
+
/* transform-origin is set inline from the computed placement so the panel
|
|
18
|
+
grows from the side closest to the anchor. */
|
|
19
|
+
animation: popoverIn 110ms ease-out;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
@keyframes popoverIn {
|
|
23
|
+
from {
|
|
24
|
+
opacity: 0;
|
|
25
|
+
transform: scale(0.96);
|
|
26
|
+
}
|
|
27
|
+
to {
|
|
28
|
+
opacity: 1;
|
|
29
|
+
transform: scale(1);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.title {
|
|
34
|
+
padding: var(--ui-space-1) var(--ui-space-3);
|
|
35
|
+
border-bottom: 1px solid var(--ui-border-subtle);
|
|
36
|
+
background-color: var(--ui-background-1);
|
|
37
|
+
font-size: var(--ui-text-xsmall);
|
|
38
|
+
font-weight: 600;
|
|
39
|
+
color: var(--ui-foreground);
|
|
40
|
+
user-select: none;
|
|
41
|
+
white-space: nowrap;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.body {
|
|
45
|
+
padding: var(--ui-space-3);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.bodyFlush {
|
|
49
|
+
padding: 0;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.actions {
|
|
53
|
+
display: flex;
|
|
54
|
+
justify-content: flex-end;
|
|
55
|
+
gap: var(--ui-space-2);
|
|
56
|
+
padding: var(--ui-space-1) var(--ui-space-3);
|
|
57
|
+
border-top: 1px solid var(--ui-border-subtle);
|
|
58
|
+
background-color: var(--ui-background-1);
|
|
59
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { ReactNode, RefObject } from 'react';
|
|
2
|
+
|
|
3
|
+
export type PopoverPlacement =
|
|
4
|
+
| 'auto'
|
|
5
|
+
| 'bottom-start'
|
|
6
|
+
| 'bottom-end'
|
|
7
|
+
| 'top-start'
|
|
8
|
+
| 'top-end';
|
|
9
|
+
|
|
10
|
+
export type PopoverProps = {
|
|
11
|
+
/** Ref of the element the popover anchors to. Popover reads its rect on open. */
|
|
12
|
+
anchorRef: RefObject<HTMLElement | null>;
|
|
13
|
+
/** Visibility — controlled by the caller. */
|
|
14
|
+
isOpen: boolean;
|
|
15
|
+
/** Fires on outside click, Escape, or when the anchor unmounts. */
|
|
16
|
+
onClose: () => void;
|
|
17
|
+
/** Optional title bar — small muted label above the body. Also becomes the
|
|
18
|
+
* accessible name when `ariaLabel` isn't provided. */
|
|
19
|
+
title?: string;
|
|
20
|
+
/** Optional actions bar — right-aligned flex row below the body. Typically
|
|
21
|
+
* a Cancel + Save pair for inline-form popovers. */
|
|
22
|
+
actions?: ReactNode;
|
|
23
|
+
/** Accessible name. Optional when `title` is provided (title labels the dialog). */
|
|
24
|
+
ariaLabel?: string;
|
|
25
|
+
/** Preferred placement relative to the anchor. `auto` picks the side with more room. */
|
|
26
|
+
placement?: PopoverPlacement;
|
|
27
|
+
/** If true, the panel's width is pinned to the anchor's width. Disables the
|
|
28
|
+
* default min-width. Used by select-style triggers so the menu aligns flush. */
|
|
29
|
+
matchAnchorWidth?: boolean;
|
|
30
|
+
/** Defaults to true. Set false for edge-to-edge body content like option lists. */
|
|
31
|
+
hasBodyPadding?: boolean;
|
|
32
|
+
/** Popover body contents — typically a small form or inline editor. */
|
|
33
|
+
children: ReactNode;
|
|
34
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
2
|
+
import { Tooltip } from './index';
|
|
3
|
+
import type { TooltipPlacement } from './types';
|
|
4
|
+
import { Button } from '../../Forms/Button';
|
|
5
|
+
|
|
6
|
+
const meta: Meta<typeof Tooltip> = {
|
|
7
|
+
title: 'Overlays/Tooltip',
|
|
8
|
+
component: Tooltip,
|
|
9
|
+
decorators: [
|
|
10
|
+
(Story) => (
|
|
11
|
+
<div style={{ padding: 80 }}>
|
|
12
|
+
<Story />
|
|
13
|
+
</div>
|
|
14
|
+
),
|
|
15
|
+
],
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export default meta;
|
|
19
|
+
type Story = StoryObj<typeof Tooltip>;
|
|
20
|
+
|
|
21
|
+
export const Default: Story = {
|
|
22
|
+
render: () => (
|
|
23
|
+
<Tooltip label="Hello, tooltip">
|
|
24
|
+
<Button>Hover or focus me</Button>
|
|
25
|
+
</Tooltip>
|
|
26
|
+
),
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const PLACEMENTS: TooltipPlacement[] = ['top', 'bottom', 'left', 'right'];
|
|
30
|
+
|
|
31
|
+
export const Placements: Story = {
|
|
32
|
+
render: () => (
|
|
33
|
+
<div style={{ display: 'flex', gap: 32, flexWrap: 'wrap' }}>
|
|
34
|
+
{PLACEMENTS.map((placement) => (
|
|
35
|
+
<Tooltip key={placement} label={`Placement: ${placement}`} placement={placement}>
|
|
36
|
+
<Button>{placement}</Button>
|
|
37
|
+
</Tooltip>
|
|
38
|
+
))}
|
|
39
|
+
</div>
|
|
40
|
+
),
|
|
41
|
+
};
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useEffect,
|
|
3
|
+
useLayoutEffect,
|
|
4
|
+
useRef,
|
|
5
|
+
useState,
|
|
6
|
+
type CSSProperties,
|
|
7
|
+
} from 'react';
|
|
8
|
+
import { createPortal } from 'react-dom';
|
|
9
|
+
import type { TooltipPlacement, TooltipProps } from './types';
|
|
10
|
+
import styles from './styles.module.css';
|
|
11
|
+
|
|
12
|
+
const ENTER_DELAY_MS = 400;
|
|
13
|
+
const GAP = 4;
|
|
14
|
+
|
|
15
|
+
type Position = { top: number; left: number };
|
|
16
|
+
|
|
17
|
+
const computePosition = (
|
|
18
|
+
host: DOMRect,
|
|
19
|
+
tip: DOMRect,
|
|
20
|
+
placement: TooltipPlacement,
|
|
21
|
+
): Position => {
|
|
22
|
+
switch (placement) {
|
|
23
|
+
case 'top':
|
|
24
|
+
return {
|
|
25
|
+
top: host.top - tip.height - GAP,
|
|
26
|
+
left: host.left + (host.width - tip.width) / 2,
|
|
27
|
+
};
|
|
28
|
+
case 'bottom':
|
|
29
|
+
return {
|
|
30
|
+
top: host.bottom + GAP,
|
|
31
|
+
left: host.left + (host.width - tip.width) / 2,
|
|
32
|
+
};
|
|
33
|
+
case 'left':
|
|
34
|
+
return {
|
|
35
|
+
top: host.top + (host.height - tip.height) / 2,
|
|
36
|
+
left: host.left - tip.width - GAP,
|
|
37
|
+
};
|
|
38
|
+
case 'right':
|
|
39
|
+
return {
|
|
40
|
+
top: host.top + (host.height - tip.height) / 2,
|
|
41
|
+
left: host.right + GAP,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export const Tooltip = (props: TooltipProps) => {
|
|
47
|
+
const { label, placement = 'top', children } = props;
|
|
48
|
+
|
|
49
|
+
const hostRef = useRef<HTMLSpanElement>(null);
|
|
50
|
+
const tipRef = useRef<HTMLSpanElement>(null);
|
|
51
|
+
const enterTimer = useRef<number | null>(null);
|
|
52
|
+
|
|
53
|
+
const [isVisible, setIsVisible] = useState(false);
|
|
54
|
+
const [position, setPosition] = useState<Position | null>(null);
|
|
55
|
+
|
|
56
|
+
const open = () => {
|
|
57
|
+
if (enterTimer.current != null) return;
|
|
58
|
+
enterTimer.current = window.setTimeout(() => {
|
|
59
|
+
enterTimer.current = null;
|
|
60
|
+
setIsVisible(true);
|
|
61
|
+
}, ENTER_DELAY_MS);
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const close = () => {
|
|
65
|
+
if (enterTimer.current != null) {
|
|
66
|
+
clearTimeout(enterTimer.current);
|
|
67
|
+
enterTimer.current = null;
|
|
68
|
+
}
|
|
69
|
+
setIsVisible(false);
|
|
70
|
+
setPosition(null);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
useEffect(
|
|
74
|
+
() => () => {
|
|
75
|
+
if (enterTimer.current != null) clearTimeout(enterTimer.current);
|
|
76
|
+
},
|
|
77
|
+
[],
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
useLayoutEffect(() => {
|
|
81
|
+
if (!isVisible) return;
|
|
82
|
+
const host = hostRef.current;
|
|
83
|
+
const tip = tipRef.current;
|
|
84
|
+
if (!host || !tip) return;
|
|
85
|
+
setPosition(
|
|
86
|
+
computePosition(host.getBoundingClientRect(), tip.getBoundingClientRect(), placement),
|
|
87
|
+
);
|
|
88
|
+
}, [isVisible, placement, label]);
|
|
89
|
+
|
|
90
|
+
const tipStyle: CSSProperties = position
|
|
91
|
+
? { top: position.top, left: position.left }
|
|
92
|
+
: { top: 0, left: 0, visibility: 'hidden' };
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<span
|
|
96
|
+
ref={hostRef}
|
|
97
|
+
className={styles.host}
|
|
98
|
+
onMouseEnter={open}
|
|
99
|
+
onMouseLeave={close}
|
|
100
|
+
onFocus={open}
|
|
101
|
+
onBlur={close}
|
|
102
|
+
>
|
|
103
|
+
{children}
|
|
104
|
+
{isVisible &&
|
|
105
|
+
createPortal(
|
|
106
|
+
<span ref={tipRef} role="tooltip" className={styles.tip} style={tipStyle}>
|
|
107
|
+
{label}
|
|
108
|
+
</span>,
|
|
109
|
+
document.body,
|
|
110
|
+
)}
|
|
111
|
+
</span>
|
|
112
|
+
);
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
export type { TooltipProps, TooltipPlacement };
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
.host {
|
|
2
|
+
position: relative;
|
|
3
|
+
display: inline-flex;
|
|
4
|
+
/* Don't stretch to fill a flex/grid parent's cross axis — the JS that
|
|
5
|
+
* positions the tip measures this span and would center on the stretched
|
|
6
|
+
* width. Block parents ignore align-self, so this is harmless there. */
|
|
7
|
+
align-self: start;
|
|
8
|
+
width: fit-content;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
.tip {
|
|
12
|
+
position: fixed;
|
|
13
|
+
/* Same z-index as every other portal layer (Modal, Popover, Menu). Stacking
|
|
14
|
+
* order between layers comes from DOM-append order, not z-index. */
|
|
15
|
+
z-index: 100;
|
|
16
|
+
pointer-events: none;
|
|
17
|
+
padding: var(--ui-space-1) var(--ui-space-2);
|
|
18
|
+
background-color: var(--ui-foreground);
|
|
19
|
+
color: var(--ui-background-0);
|
|
20
|
+
font-family: var(--ui-font);
|
|
21
|
+
font-size: var(--ui-text-small);
|
|
22
|
+
line-height: 1.2;
|
|
23
|
+
border-radius: var(--ui-radius);
|
|
24
|
+
white-space: nowrap;
|
|
25
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
export type TooltipPlacement = 'top' | 'bottom' | 'left' | 'right';
|
|
4
|
+
|
|
5
|
+
export type TooltipProps = {
|
|
6
|
+
/** Tooltip text shown on hover/focus of the trigger. */
|
|
7
|
+
label: string;
|
|
8
|
+
/** Side of the trigger to render on. Defaults to `'top'`. */
|
|
9
|
+
placement?: TooltipPlacement;
|
|
10
|
+
/** The trigger element. Should be a single focusable element so that
|
|
11
|
+
* `:focus-within` reveal works. The tooltip text is purely visual —
|
|
12
|
+
* triggers should still carry their own accessible name (e.g. an
|
|
13
|
+
* `aria-label` on an IconButton). */
|
|
14
|
+
children: ReactNode;
|
|
15
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
2
|
+
import { EmptyValue } from './index';
|
|
3
|
+
|
|
4
|
+
const meta: Meta<typeof EmptyValue> = {
|
|
5
|
+
title: 'Primitives/EmptyValue',
|
|
6
|
+
component: EmptyValue,
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export default meta;
|
|
10
|
+
type Story = StoryObj<typeof EmptyValue>;
|
|
11
|
+
|
|
12
|
+
export const Default: Story = {
|
|
13
|
+
render: () => (
|
|
14
|
+
<div style={{ padding: 24 }}>
|
|
15
|
+
Last login: <EmptyValue />
|
|
16
|
+
</div>
|
|
17
|
+
),
|
|
18
|
+
};
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
2
|
+
import { LinedStack } from './index';
|
|
3
|
+
import { Text } from '../../Content/Text';
|
|
4
|
+
|
|
5
|
+
const meta: Meta<typeof LinedStack> = {
|
|
6
|
+
title: 'Primitives/LinedStack',
|
|
7
|
+
component: LinedStack,
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export default meta;
|
|
11
|
+
type Story = StoryObj<typeof LinedStack>;
|
|
12
|
+
|
|
13
|
+
const wrap = { padding: 24, display: 'flex', gap: 32 };
|
|
14
|
+
|
|
15
|
+
export const Plain: Story = {
|
|
16
|
+
name: 'Plain — non-interactive',
|
|
17
|
+
render: () => (
|
|
18
|
+
<div style={wrap}>
|
|
19
|
+
<LinedStack>
|
|
20
|
+
<strong>Alice Park</strong>
|
|
21
|
+
<Text isMuted size="xsmall">
|
|
22
|
+
alice.park@example.com
|
|
23
|
+
</Text>
|
|
24
|
+
</LinedStack>
|
|
25
|
+
</div>
|
|
26
|
+
),
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export const AsRouterLink: Story = {
|
|
30
|
+
name: 'As router link — `to` prop',
|
|
31
|
+
render: () => (
|
|
32
|
+
<div style={wrap}>
|
|
33
|
+
<LinedStack to="/users/usr_1234">
|
|
34
|
+
<strong>Alice Park</strong>
|
|
35
|
+
<Text isMuted size="xsmall">
|
|
36
|
+
alice.park@example.com
|
|
37
|
+
</Text>
|
|
38
|
+
</LinedStack>
|
|
39
|
+
</div>
|
|
40
|
+
),
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export const AsAnchor: Story = {
|
|
44
|
+
name: 'As anchor — `href` prop (typically external)',
|
|
45
|
+
render: () => (
|
|
46
|
+
<div style={wrap}>
|
|
47
|
+
<LinedStack href="https://example.com/u/alice" isExternal>
|
|
48
|
+
<strong>Alice Park</strong>
|
|
49
|
+
<Text isMuted size="xsmall">
|
|
50
|
+
example.com/u/alice
|
|
51
|
+
</Text>
|
|
52
|
+
</LinedStack>
|
|
53
|
+
</div>
|
|
54
|
+
),
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export const AsButton: Story = {
|
|
58
|
+
name: 'As button — `onClick` prop',
|
|
59
|
+
render: () => (
|
|
60
|
+
<div style={wrap}>
|
|
61
|
+
<LinedStack onClick={() => alert('clicked')}>
|
|
62
|
+
<strong>Alice Park</strong>
|
|
63
|
+
<Text isMuted size="xsmall">
|
|
64
|
+
alice.park@example.com
|
|
65
|
+
</Text>
|
|
66
|
+
</LinedStack>
|
|
67
|
+
</div>
|
|
68
|
+
),
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export const Mixed: Story = {
|
|
72
|
+
name: 'Side-by-side: plain, router link, anchor, button',
|
|
73
|
+
render: () => (
|
|
74
|
+
<div style={wrap}>
|
|
75
|
+
<LinedStack>
|
|
76
|
+
<strong>Plain</strong>
|
|
77
|
+
<Text isMuted size="xsmall">
|
|
78
|
+
no interactivity
|
|
79
|
+
</Text>
|
|
80
|
+
</LinedStack>
|
|
81
|
+
<LinedStack to="/users/usr_1234">
|
|
82
|
+
<strong>Router Link</strong>
|
|
83
|
+
<Text isMuted size="xsmall">
|
|
84
|
+
renders <Link to>
|
|
85
|
+
</Text>
|
|
86
|
+
</LinedStack>
|
|
87
|
+
<LinedStack href="https://example.com" isExternal>
|
|
88
|
+
<strong>Anchor</strong>
|
|
89
|
+
<Text isMuted size="xsmall">
|
|
90
|
+
renders <a href>
|
|
91
|
+
</Text>
|
|
92
|
+
</LinedStack>
|
|
93
|
+
<LinedStack onClick={() => alert('clicked')}>
|
|
94
|
+
<strong>Button</strong>
|
|
95
|
+
<Text isMuted size="xsmall">
|
|
96
|
+
renders <button>
|
|
97
|
+
</Text>
|
|
98
|
+
</LinedStack>
|
|
99
|
+
</div>
|
|
100
|
+
),
|
|
101
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { Link as RouterLink } from 'react-router';
|
|
2
|
+
import { cx } from '../../../utils';
|
|
3
|
+
import type { LinedStackProps } from './types';
|
|
4
|
+
import styles from './styles.module.css';
|
|
5
|
+
|
|
6
|
+
export const LinedStack = (props: LinedStackProps) => {
|
|
7
|
+
const { children, to, href, isExternal, onClick } = props;
|
|
8
|
+
|
|
9
|
+
if (to != null) {
|
|
10
|
+
return (
|
|
11
|
+
<RouterLink to={to} className={cx(styles.stack, styles.interactive)}>
|
|
12
|
+
{children}
|
|
13
|
+
</RouterLink>
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (href != null) {
|
|
18
|
+
return (
|
|
19
|
+
<a
|
|
20
|
+
href={href}
|
|
21
|
+
target={isExternal ? '_blank' : undefined}
|
|
22
|
+
rel={isExternal ? 'noopener noreferrer' : undefined}
|
|
23
|
+
className={cx(styles.stack, styles.interactive)}
|
|
24
|
+
>
|
|
25
|
+
{children}
|
|
26
|
+
</a>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (onClick != null) {
|
|
31
|
+
return (
|
|
32
|
+
<button type="button" onClick={onClick} className={cx(styles.stack, styles.interactive)}>
|
|
33
|
+
{children}
|
|
34
|
+
</button>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return <div className={styles.stack}>{children}</div>;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export type { LinedStackProps };
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
.stack {
|
|
2
|
+
display: flex;
|
|
3
|
+
flex-direction: column;
|
|
4
|
+
align-items: flex-start;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
.interactive {
|
|
8
|
+
cursor: pointer;
|
|
9
|
+
text-decoration: none;
|
|
10
|
+
color: inherit;
|
|
11
|
+
background: none;
|
|
12
|
+
border: none;
|
|
13
|
+
padding: 0;
|
|
14
|
+
font: inherit;
|
|
15
|
+
text-align: left;
|
|
16
|
+
border-radius: var(--ui-radius);
|
|
17
|
+
transition: background-color 0.12s;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
.interactive:hover {
|
|
21
|
+
background-color: var(--ui-background-1);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.interactive:focus-visible {
|
|
25
|
+
outline: 2px solid var(--ui-accent);
|
|
26
|
+
outline-offset: 2px;
|
|
27
|
+
}
|