@sproutsocial/seeds-react-menu 1.10.8 → 1.11.0

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.
Files changed (50) hide show
  1. package/.turbo/turbo-build.log +21 -15
  2. package/CHANGELOG.md +22 -0
  3. package/dist/esm/index.js +45 -24
  4. package/dist/esm/index.js.map +1 -1
  5. package/dist/esm/v2/index.js +1 -1
  6. package/dist/esm/v2/index.js.map +1 -1
  7. package/dist/esm/v3/index.js +800 -0
  8. package/dist/esm/v3/index.js.map +1 -0
  9. package/dist/index.d.mts +22 -22
  10. package/dist/index.d.ts +22 -22
  11. package/dist/index.js +35 -14
  12. package/dist/index.js.map +1 -1
  13. package/dist/v2/index.js +1 -1
  14. package/dist/v2/index.js.map +1 -1
  15. package/dist/v3/index.d.mts +295 -0
  16. package/dist/v3/index.d.ts +295 -0
  17. package/dist/v3/index.js +868 -0
  18. package/dist/v3/index.js.map +1 -0
  19. package/package.json +7 -1
  20. package/src/MenuGroup/MenuGroup.tsx +16 -3
  21. package/src/MenuGroup/__tests__/MenuGroup.test.tsx +19 -0
  22. package/src/MenuItem/MenuItem.tsx +21 -10
  23. package/src/MenuItem/__tests__/MenuItem.test.tsx +19 -0
  24. package/src/v2/Common-V2/ComboboxContent.tsx +1 -1
  25. package/src/v3/Common/ComboboxItem.tsx +82 -0
  26. package/src/v3/Common/ComboboxStyles.tsx +475 -0
  27. package/src/v3/Common/SelectGroup.tsx +28 -0
  28. package/src/v3/Common/SelectIcons.tsx +26 -0
  29. package/src/v3/Common/SelectItem.tsx +108 -0
  30. package/src/v3/Common/SelectStyles.tsx +126 -0
  31. package/src/v3/Common/VirtualizedComboboxList.tsx +200 -0
  32. package/src/v3/Common/groupItems.ts +12 -0
  33. package/src/v3/Common/types.ts +3 -0
  34. package/src/v3/ModalStories.stories.tsx +181 -0
  35. package/src/v3/MultiCombobox.stories.tsx +322 -0
  36. package/src/v3/MultiCombobox.tsx +562 -0
  37. package/src/v3/MultiSearchableSelect.stories.tsx +389 -0
  38. package/src/v3/MultiSearchableSelect.tsx +545 -0
  39. package/src/v3/MultiSelect.stories.tsx +300 -0
  40. package/src/v3/MultiSelect.tsx +498 -0
  41. package/src/v3/SeedsPortal.tsx +35 -0
  42. package/src/v3/SingleCombobox.stories.tsx +169 -0
  43. package/src/v3/SingleCombobox.tsx +304 -0
  44. package/src/v3/SingleSearchableSelect.stories.tsx +239 -0
  45. package/src/v3/SingleSearchableSelect.tsx +319 -0
  46. package/src/v3/SingleSelect.stories.tsx +227 -0
  47. package/src/v3/SingleSelect.tsx +245 -0
  48. package/src/v3/index.ts +7 -0
  49. package/src/v3/storyData.tsx +117 -0
  50. package/tsup.config.ts +1 -0
@@ -0,0 +1,126 @@
1
+ import { Select } from "@base-ui/react/select";
2
+ import styled from "styled-components";
3
+ import { focusRing } from "@sproutsocial/seeds-react-mixins";
4
+
5
+ export const Field = styled.div`
6
+ display: flex;
7
+ flex-direction: column;
8
+ gap: ${({ theme }) => theme.space[200]};
9
+ `;
10
+
11
+ export const SelectLabel = styled(Select.Label)`
12
+ font-size: ${({ theme }) => theme.typography[200].fontSize};
13
+ font-weight: ${({ theme }) => theme.fontWeights.semibold};
14
+ color: ${({ theme }) => theme.colors.text.body};
15
+ `;
16
+
17
+ export const SelectTrigger = styled(Select.Trigger)`
18
+ display: flex;
19
+ align-items: center;
20
+ justify-content: space-between;
21
+ gap: ${({ theme }) => theme.space[300]};
22
+ padding: ${({ theme }) => `${theme.space[300]}`};
23
+ min-width: 160px;
24
+ border-radius: ${({ theme }) => theme.radii[500]};
25
+ border: 1px solid ${({ theme }) => theme.colors.form.border.base};
26
+ background: ${({ theme }) => theme.colors.form.background.base};
27
+ font-size: ${({ theme }) => theme.typography[200].fontSize};
28
+ color: ${({ theme }) => theme.colors.text.body};
29
+ cursor: default;
30
+ outline: none;
31
+ transition: border-color ${({ theme }) => theme.duration.fast}
32
+ ${({ theme }) => theme.easing.ease_in},
33
+ box-shadow ${({ theme }) => theme.duration.fast}
34
+ ${({ theme }) => theme.easing.ease_in};
35
+
36
+ &:focus-visible {
37
+ ${focusRing}
38
+ }
39
+
40
+ &[data-popup-open] {
41
+ ${focusRing}
42
+ }
43
+
44
+ &[data-popup-open] [data-chevron] {
45
+ transform: rotate(-180deg);
46
+ }
47
+ `;
48
+
49
+ export const SelectValue = styled(Select.Value)`
50
+ min-width: 0;
51
+ overflow: hidden;
52
+ `;
53
+
54
+ export const SelectIcon = styled(Select.Icon)`
55
+ display: flex;
56
+ align-items: center;
57
+ color: ${({ theme }) => theme.colors.icon.base};
58
+ flex-shrink: 0;
59
+ `;
60
+
61
+ export const SelectGroup = styled(Select.Group)`
62
+ display: block;
63
+ `;
64
+
65
+ export const SelectGroupLabel = styled(Select.GroupLabel)`
66
+ padding: ${({ theme }) =>
67
+ `${theme.space[300]} ${theme.space[300]} ${theme.space[200]}`};
68
+ font-size: ${({ theme }) => theme.typography[100].fontSize};
69
+ font-weight: ${({ theme }) => theme.fontWeights.semibold};
70
+ text-transform: uppercase;
71
+ letter-spacing: 0.05em;
72
+ color: ${({ theme }) => theme.colors.text.subtext};
73
+ `;
74
+
75
+ export const SelectSeparator = styled(Select.Separator)`
76
+ height: 1px;
77
+ margin: ${({ theme }) => `${theme.space[200]} ${theme.space[300]}`};
78
+ background: ${({ theme }) => theme.colors.container.border.base};
79
+ `;
80
+
81
+ export const SelectPositioner = styled(Select.Positioner)`
82
+ width: var(--anchor-width);
83
+ z-index: 7;
84
+ `;
85
+
86
+ export const SelectPopup = styled(Select.Popup)`
87
+ font-family: ${({ theme }) => theme.fontFamily};
88
+ background: ${({ theme }) => theme.colors.container.background.base};
89
+ border: 1px solid ${({ theme }) => theme.colors.container.border.base};
90
+ border-radius: ${({ theme }) => theme.radii[500]};
91
+ box-shadow: ${({ theme }) => theme.shadows.low};
92
+ outline: none;
93
+ max-height: min(var(--base-ui-available-height, 400px), 400px);
94
+ overflow-y: auto;
95
+ padding: ${({ theme }) => theme.space[200]};
96
+
97
+ &[data-open] {
98
+ animation: select-popup-in 120ms ease-out;
99
+ }
100
+
101
+ &[data-closed] {
102
+ animation: select-popup-out 100ms ease-in;
103
+ }
104
+
105
+ @keyframes select-popup-in {
106
+ from {
107
+ opacity: 0;
108
+ transform: scale(0.95);
109
+ }
110
+ to {
111
+ opacity: 1;
112
+ transform: scale(1);
113
+ }
114
+ }
115
+
116
+ @keyframes select-popup-out {
117
+ from {
118
+ opacity: 1;
119
+ transform: scale(1);
120
+ }
121
+ to {
122
+ opacity: 0;
123
+ transform: scale(0.95);
124
+ }
125
+ }
126
+ `;
@@ -0,0 +1,200 @@
1
+ import * as React from "react";
2
+ import { Combobox } from "@base-ui/react/combobox";
3
+ import { useVirtualizer } from "@tanstack/react-virtual";
4
+ import ComboboxItem from "./ComboboxItem";
5
+ import {
6
+ ComboboxGroupHeaderItem,
7
+ ComboboxGroupHeaderCheckbox,
8
+ ComboboxSelectAllItem,
9
+ } from "./ComboboxStyles";
10
+ import type { DataWithId } from "./types";
11
+
12
+ export const ITEM_HEIGHT = 32;
13
+
14
+ interface GroupHeaderSentinel {
15
+ __groupHeader: true;
16
+ groupName: string;
17
+ }
18
+
19
+ interface SelectAllSentinel {
20
+ __selectAll: true;
21
+ }
22
+
23
+ export function isGroupHeaderSentinel(v: unknown): v is GroupHeaderSentinel {
24
+ return typeof v === "object" && v !== null && "__groupHeader" in v;
25
+ }
26
+
27
+ export function isSelectAllSentinel(v: unknown): v is SelectAllSentinel {
28
+ return typeof v === "object" && v !== null && "__selectAll" in v;
29
+ }
30
+
31
+ export type FlatSentinelRow<T> = T | GroupHeaderSentinel | SelectAllSentinel;
32
+
33
+ interface VirtualizedComboboxListProps<T extends DataWithId> {
34
+ open: boolean;
35
+ virtualizerRef: React.RefObject<ReturnType<
36
+ typeof useVirtualizer<HTMLDivElement, Element>
37
+ > | null>;
38
+ value: T | T[] | null;
39
+ groups: Array<{ value: string; items: T[] }> | null;
40
+ data: T[];
41
+ itemToString: (item: T) => string;
42
+ inputType: "icon" | "radio" | "checkbox" | "none";
43
+ renderItem?: (item: T) => React.ReactNode;
44
+ renderGroupHeading?: (label: string) => React.ReactNode;
45
+ }
46
+
47
+ export default function VirtualizedComboboxList<T extends DataWithId>({
48
+ open,
49
+ virtualizerRef,
50
+ value,
51
+ groups,
52
+ data,
53
+ itemToString,
54
+ inputType,
55
+ renderItem,
56
+ renderGroupHeading,
57
+ }: VirtualizedComboboxListProps<T>) {
58
+ type Row = FlatSentinelRow<T>;
59
+
60
+ const filteredItems = Combobox.useFilteredItems<Row>();
61
+ const scrollElementRef = React.useRef<HTMLDivElement | null>(null);
62
+
63
+ const virtualizer = useVirtualizer({
64
+ enabled: open,
65
+ count: filteredItems.length,
66
+ getScrollElement: () => scrollElementRef.current,
67
+ estimateSize: () => ITEM_HEIGHT,
68
+ overscan: 20,
69
+ paddingStart: 4,
70
+ paddingEnd: 4,
71
+ scrollPaddingStart: 4,
72
+ scrollPaddingEnd: 4,
73
+ });
74
+
75
+ React.useImperativeHandle(virtualizerRef, () => virtualizer);
76
+
77
+ const handleScrollRef = React.useCallback(
78
+ (el: HTMLDivElement | null) => {
79
+ scrollElementRef.current = el;
80
+ if (el) virtualizer.measure();
81
+ },
82
+ [virtualizer]
83
+ );
84
+
85
+ if (!filteredItems.length) return null;
86
+
87
+ const selectedArray = Array.isArray(value)
88
+ ? value
89
+ : value != null
90
+ ? [value]
91
+ : [];
92
+
93
+ const totalSize = virtualizer.getTotalSize();
94
+
95
+ return (
96
+ <div
97
+ role="presentation"
98
+ ref={handleScrollRef}
99
+ style={{
100
+ flex: 1,
101
+ minHeight: 0,
102
+ overflowY: "auto",
103
+ overscrollBehavior: "contain",
104
+ }}
105
+ >
106
+ <div
107
+ role="presentation"
108
+ style={{ position: "relative", width: "100%", height: totalSize }}
109
+ >
110
+ {virtualizer.getVirtualItems().map((virtualItem) => {
111
+ const row = filteredItems[virtualItem.index];
112
+ if (!row) return null;
113
+
114
+ const baseStyle: React.CSSProperties = {
115
+ position: "absolute",
116
+ top: 0,
117
+ left: 0,
118
+ width: "100%",
119
+ transform: `translateY(${virtualItem.start}px)`,
120
+ boxSizing: "border-box",
121
+ };
122
+
123
+ if (isSelectAllSentinel(row)) {
124
+ const selectedCount = selectedArray.length;
125
+ const totalCount = data.length;
126
+ const state =
127
+ selectedCount === 0
128
+ ? "none"
129
+ : selectedCount === totalCount
130
+ ? "all"
131
+ : "partial";
132
+ return (
133
+ <ComboboxSelectAllItem
134
+ key={virtualItem.key}
135
+ index={virtualItem.index}
136
+ data-index={virtualItem.index}
137
+ value={row}
138
+ style={baseStyle}
139
+ ref={virtualizer.measureElement}
140
+ >
141
+ <ComboboxGroupHeaderCheckbox $state={state} id="__selectAll" />
142
+ Select all
143
+ </ComboboxSelectAllItem>
144
+ );
145
+ }
146
+
147
+ if (isGroupHeaderSentinel(row)) {
148
+ const group = groups?.find((g) => g.value === row.groupName);
149
+ const groupItems = group?.items ?? [];
150
+ const selectedCount = groupItems.filter((item) =>
151
+ selectedArray.some((s) => s.id === item.id)
152
+ ).length;
153
+ const state =
154
+ selectedCount === 0
155
+ ? "none"
156
+ : selectedCount === groupItems.length
157
+ ? "all"
158
+ : "partial";
159
+ return (
160
+ <ComboboxGroupHeaderItem
161
+ key={virtualItem.key}
162
+ index={virtualItem.index}
163
+ data-index={virtualItem.index}
164
+ value={row}
165
+ style={baseStyle}
166
+ ref={virtualizer.measureElement}
167
+ >
168
+ <ComboboxGroupHeaderCheckbox
169
+ $state={state}
170
+ id={`__header-${row.groupName}`}
171
+ />
172
+ {renderGroupHeading
173
+ ? renderGroupHeading(row.groupName)
174
+ : row.groupName}
175
+ </ComboboxGroupHeaderItem>
176
+ );
177
+ }
178
+
179
+ const item = row as T;
180
+ return (
181
+ <ComboboxItem
182
+ key={virtualItem.key}
183
+ index={virtualItem.index}
184
+ data-index={virtualItem.index}
185
+ value={item}
186
+ itemId={String(item.id)}
187
+ label={itemToString(item)}
188
+ inputType={inputType}
189
+ renderItem={
190
+ renderItem ? () => renderItem(item) : () => itemToString(item)
191
+ }
192
+ style={baseStyle}
193
+ ref={virtualizer.measureElement}
194
+ />
195
+ );
196
+ })}
197
+ </div>
198
+ </div>
199
+ );
200
+ }
@@ -0,0 +1,12 @@
1
+ export function groupItems<T>(
2
+ data: T[],
3
+ groupBy: (item: T) => string
4
+ ): Array<{ value: string; items: T[] }> {
5
+ const map = new Map<string, T[]>();
6
+ for (const item of data) {
7
+ const key = groupBy(item);
8
+ if (!map.has(key)) map.set(key, []);
9
+ map.get(key)!.push(item);
10
+ }
11
+ return Array.from(map.entries()).map(([value, items]) => ({ value, items }));
12
+ }
@@ -0,0 +1,3 @@
1
+ export interface DataWithId {
2
+ id: string | number;
3
+ }
@@ -0,0 +1,181 @@
1
+ import React from "react";
2
+ import type { Meta, StoryObj } from "@storybook/react";
3
+ import { FormField } from "@sproutsocial/seeds-react-form-field";
4
+ import { Button } from "@sproutsocial/seeds-react-button";
5
+ import { Input } from "@sproutsocial/seeds-react-input";
6
+ import { TokenInput } from "@sproutsocial/seeds-react-token-input";
7
+ import {
8
+ Modal as ModalV1,
9
+ ModalV2,
10
+ ModalBody,
11
+ ModalFooter,
12
+ } from "@sproutsocial/seeds-react-modal";
13
+ import SingleSelect from "./SingleSelect";
14
+ import MultiSelect from "./MultiSelect";
15
+ import SingleCombobox from "./SingleCombobox";
16
+ import MultiCombobox from "./MultiCombobox";
17
+ import SingleSearchableSelect from "./SingleSearchableSelect";
18
+ import MultiSearchableSelect from "./MultiSearchableSelect";
19
+ import { books, itemToString } from "./storyData";
20
+
21
+ // ─── Shared content ───────────────────────────────────────────────────────────
22
+
23
+ function AllComponents() {
24
+ return (
25
+ <div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
26
+ <FormField label="Input`">
27
+ {({ id }) => (
28
+ <Input
29
+ id={id}
30
+ name="input"
31
+ value="Some Value"
32
+ placeholder="Select a book"
33
+ />
34
+ )}
35
+ </FormField>
36
+ <FormField label="Token Input">
37
+ {({ id }) => (
38
+ <TokenInput
39
+ id={id}
40
+ name="token-input"
41
+ tokens={[{ id: "token", value: "Token" }]}
42
+ value="Some Value"
43
+ placeholder="Select a book"
44
+ />
45
+ )}
46
+ </FormField>
47
+ <FormField label="Token Input2">
48
+ {({ id }) => (
49
+ <TokenInput
50
+ id={id}
51
+ name="token-input"
52
+ tokens={[]}
53
+ value="Some Value"
54
+ placeholder="Select a book"
55
+ />
56
+ )}
57
+ </FormField>
58
+ <FormField label="Single Select">
59
+ {({ id }) => (
60
+ <SingleSelect
61
+ id={id}
62
+ data={books}
63
+ itemToString={itemToString}
64
+ placeholder="Select a book"
65
+ />
66
+ )}
67
+ </FormField>
68
+ <FormField label="Multi Select">
69
+ {({ id }) => (
70
+ <MultiSelect
71
+ id={id}
72
+ data={books}
73
+ itemToString={itemToString}
74
+ placeholder="Select books"
75
+ />
76
+ )}
77
+ </FormField>
78
+ <FormField label="Single Combobox">
79
+ {({ id }) => (
80
+ <SingleCombobox
81
+ id={id}
82
+ data={books}
83
+ itemToString={itemToString}
84
+ placeholder="Search books..."
85
+ />
86
+ )}
87
+ </FormField>
88
+ <FormField label="Multi Combobox">
89
+ {({ id }) => (
90
+ <MultiCombobox
91
+ id={id}
92
+ data={books}
93
+ itemToString={itemToString}
94
+ placeholder="Search books..."
95
+ />
96
+ )}
97
+ </FormField>
98
+ <FormField label="Single Searchable Select">
99
+ {({ id }) => (
100
+ <SingleSearchableSelect
101
+ id={id}
102
+ data={books}
103
+ itemToString={itemToString}
104
+ placeholder="Select a book"
105
+ searchPlaceholder="Search books..."
106
+ />
107
+ )}
108
+ </FormField>
109
+ <FormField label="Multi Searchable Select">
110
+ {({ id }) => (
111
+ <MultiSearchableSelect
112
+ id={id}
113
+ data={books}
114
+ itemToString={itemToString}
115
+ placeholder="Select books"
116
+ searchPlaceholder="Search books..."
117
+ />
118
+ )}
119
+ </FormField>
120
+ </div>
121
+ );
122
+ }
123
+
124
+ // ─── Meta ─────────────────────────────────────────────────────────────────────
125
+
126
+ const meta: Meta = {
127
+ title: "Really Under Development/V3/In Modal",
128
+ };
129
+
130
+ export default meta;
131
+ type Story = StoryObj;
132
+
133
+ // ─── Stories ──────────────────────────────────────────────────────────────────
134
+
135
+ export const V1Modal: Story = {
136
+ name: "V1 Modal",
137
+ render: () => {
138
+ const [isOpen, setIsOpen] = React.useState(false);
139
+ return (
140
+ <>
141
+ <Button appearance="primary" onClick={() => setIsOpen(true)}>
142
+ Open V1 Modal
143
+ </Button>
144
+ <ModalV1
145
+ appElementSelector="#root"
146
+ isOpen={isOpen}
147
+ onClose={() => setIsOpen(false)}
148
+ closeButtonLabel="Close"
149
+ label="V1 Modal"
150
+ >
151
+ <ModalV1.Header title="" subtitle="" bordered>
152
+ V1 modal
153
+ </ModalV1.Header>
154
+ <ModalV1.Content>
155
+ <AllComponents />
156
+ </ModalV1.Content>
157
+ <ModalV1.Footer>Footer</ModalV1.Footer>
158
+ </ModalV1>
159
+ </>
160
+ );
161
+ },
162
+ };
163
+
164
+ export const V2Modal: Story = {
165
+ name: "V2 Modal",
166
+ render: () => (
167
+ <>
168
+ <ModalV2
169
+ title="Pick some books"
170
+ modalTrigger={<Button appearance="primary">Open V2 Modal</Button>}
171
+ closeButtonAriaLabel="Close"
172
+ >
173
+ <ModalBody>
174
+ <AllComponents />
175
+ </ModalBody>
176
+ <ModalFooter cancelButton={<Button>Cancel</Button>} />
177
+ </ModalV2>
178
+ <AllComponents />
179
+ </>
180
+ ),
181
+ };