@sproutsocial/seeds-react-tree 0.4.0 → 0.4.3

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.
@@ -1,71 +1,12 @@
1
1
  import * as React from "react";
2
- import styled from "styled-components";
3
2
  import { Icon } from "@sproutsocial/seeds-react-icon";
4
- import { focusRing } from "@sproutsocial/seeds-react-mixins";
5
3
  import { Tree, type TreeProps } from "./Tree";
6
4
  import { TreeItem } from "./TreeItem";
5
+ import { cn } from "./cn";
7
6
  import { filterTree } from "./Common/filterTree";
8
7
  import { computeTreeNavigation, treeItemDomId } from "./Common/treeNavigation";
9
8
  import type { TreeItemData } from "./Common/types";
10
9
 
11
- const Root = styled.div`
12
- display: flex;
13
- flex-direction: column;
14
- flex: 1;
15
- min-height: 0;
16
- `;
17
-
18
- const SearchSection = styled.div`
19
- padding: ${({ theme }) => theme.space[300]};
20
- border-bottom: 1px solid ${({ theme }) => theme.colors.container.border.base};
21
- flex-shrink: 0;
22
- `;
23
-
24
- const InputGroup = styled.div`
25
- display: flex;
26
- align-items: center;
27
- gap: ${({ theme }) => theme.space[200]};
28
- padding: ${({ theme }) => theme.space[200]} ${({ theme }) => theme.space[300]};
29
- border-radius: ${({ theme }) => theme.radii[500]};
30
- border: 1px solid ${({ theme }) => theme.colors.container.border.base};
31
- background: ${({ theme }) => theme.colors.container.background.base};
32
- color: ${({ theme }) => theme.colors.icon.base};
33
-
34
- &:focus-within {
35
- ${focusRing}
36
- }
37
- `;
38
-
39
- const ComboboxInput = styled.input`
40
- flex: 1;
41
- border: none;
42
- background: transparent;
43
- outline: none;
44
- font-family: ${({ theme }) => theme.fontFamily};
45
- font-size: ${({ theme }) => theme.typography[200].fontSize};
46
- line-height: ${({ theme }) => theme.typography[200].lineHeight};
47
- color: ${({ theme }) => theme.colors.text.body};
48
-
49
- &::placeholder {
50
- color: ${({ theme }) => theme.colors.text.subtext};
51
- }
52
- `;
53
-
54
- const ListSection = styled.div`
55
- padding: ${({ theme }) => theme.space[200]};
56
- overflow: auto;
57
- flex: 1;
58
- min-height: 0;
59
- `;
60
-
61
- const EmptyState = styled.div`
62
- padding: ${({ theme }) => `${theme.space[300]} ${theme.space[350]}`};
63
- text-align: center;
64
- color: ${({ theme }) => theme.colors.text.subtext};
65
- font-size: ${({ theme }) => theme.typography[200].fontSize};
66
- line-height: ${({ theme }) => theme.typography[200].lineHeight};
67
- `;
68
-
69
10
  const NAV_KEYS = new Set([
70
11
  "ArrowDown",
71
12
  "ArrowUp",
@@ -256,11 +197,12 @@ export function TreeCombobox(props: TreeComboboxProps) {
256
197
  const showEmpty = visibleItems.length === 0;
257
198
 
258
199
  return (
259
- <Root className={className}>
260
- <SearchSection>
261
- <InputGroup>
200
+ <div className={cn("seeds-tree-combobox", className)}>
201
+ <div className="seeds-tree-combobox-search">
202
+ <div className="seeds-tree-combobox-input-group">
262
203
  <Icon name="magnifying-glass-outline" size="small" aria-hidden />
263
- <ComboboxInput
204
+ <input
205
+ className="seeds-tree-combobox-input"
264
206
  type="text"
265
207
  role="combobox"
266
208
  id={id}
@@ -278,10 +220,10 @@ export function TreeCombobox(props: TreeComboboxProps) {
278
220
  onChange={(e) => setQuery(e.target.value)}
279
221
  onKeyDown={handleInputKeyDown}
280
222
  />
281
- </InputGroup>
282
- </SearchSection>
223
+ </div>
224
+ </div>
283
225
 
284
- <ListSection>
226
+ <div className="seeds-tree-combobox-list">
285
227
  <Tree
286
228
  ref={treeRef}
287
229
  aria-label={ariaLabel}
@@ -305,12 +247,16 @@ export function TreeCombobox(props: TreeComboboxProps) {
305
247
  </Tree>
306
248
 
307
249
  {showEmpty ? (
308
- <EmptyState role="status" aria-live="polite">
250
+ <div
251
+ className="seeds-tree-combobox-empty"
252
+ role="status"
253
+ aria-live="polite"
254
+ >
309
255
  {emptyText}
310
- </EmptyState>
256
+ </div>
311
257
  ) : null}
312
- </ListSection>
313
- </Root>
258
+ </div>
259
+ </div>
314
260
  );
315
261
  }
316
262
 
package/src/TreeItem.tsx CHANGED
@@ -3,14 +3,7 @@ import { Icon } from "@sproutsocial/seeds-react-icon";
3
3
  import { TreeItemContext, useTreeContext } from "./Common/treeContext";
4
4
  import { useTreeKeyboard } from "./Common/useTreeKeyboard";
5
5
  import { treeItemDomId } from "./Common/treeNavigation";
6
- import {
7
- TreeItemChevron,
8
- TreeItemEl,
9
- TreeItemGroup,
10
- TreeItemIcon,
11
- TreeItemLabel,
12
- TreeItemRow,
13
- } from "./TreeStyles";
6
+ import { cn } from "./cn";
14
7
 
15
8
  export type TreeItemProps = {
16
9
  id: string;
@@ -127,8 +120,9 @@ export function TreeItem(props: TreeItemProps) {
127
120
  const labelId = `${id}__label`;
128
121
 
129
122
  return (
130
- <TreeItemEl
123
+ <li
131
124
  ref={itemRef}
125
+ className="seeds-tree-item"
132
126
  id={treeItemDomId(id)}
133
127
  role="treeitem"
134
128
  data-treeitem-id={id}
@@ -174,11 +168,13 @@ export function TreeItem(props: TreeItemProps) {
174
168
  if (ctx.focusedId !== id) ctx.setFocusedId(id);
175
169
  }}
176
170
  >
177
- <TreeItemRow
171
+ <div
178
172
  data-treeitem-row
179
- $level={level}
180
- $selected={isSelected}
181
- $disabled={disabled}
173
+ className={cn("seeds-tree-item-row", {
174
+ selected: isSelected,
175
+ disabled,
176
+ })}
177
+ style={{ "--tree-level": level } as React.CSSProperties}
182
178
  >
183
179
  {ctx.renderSelectionIndicator &&
184
180
  !(ctx.selectableNodes === "leaves" && hasChildren)
@@ -189,29 +185,44 @@ export function TreeItem(props: TreeItemProps) {
189
185
  })
190
186
  : null}
191
187
 
192
- {icon != null ? <TreeItemIcon aria-hidden>{icon}</TreeItemIcon> : null}
193
- <TreeItemLabel id={labelId}>{label}</TreeItemLabel>
188
+ {icon != null ? (
189
+ <span className="seeds-tree-item-icon" aria-hidden>
190
+ {icon}
191
+ </span>
192
+ ) : null}
193
+ <span className="seeds-tree-item-label" id={labelId}>
194
+ {label}
195
+ </span>
194
196
 
195
197
  {hasChildren ? (
196
- <TreeItemChevron
198
+ <button
199
+ type="button"
200
+ tabIndex={-1}
197
201
  data-tree-chevron
198
202
  aria-hidden
199
- $expanded={isExpanded}
203
+ className={cn("seeds-tree-item-chevron", {
204
+ expanded: isExpanded,
205
+ })}
200
206
  onClick={handleChevronClick}
201
207
  >
202
208
  <Icon name="chevron-down-outline" size="small" />
203
- </TreeItemChevron>
209
+ </button>
204
210
  ) : null}
205
- </TreeItemRow>
211
+ </div>
206
212
 
207
213
  {hasChildren ? (
208
- <TreeItemGroup role="group" id={groupId} hidden={!isExpanded}>
214
+ <ul
215
+ className="seeds-tree-item-group"
216
+ role="group"
217
+ id={groupId}
218
+ hidden={!isExpanded}
219
+ >
209
220
  <TreeItemContext.Provider value={{ level: level + 1 }}>
210
221
  {children}
211
222
  </TreeItemContext.Provider>
212
- </TreeItemGroup>
223
+ </ul>
213
224
  ) : null}
214
- </TreeItemEl>
225
+ </li>
215
226
  );
216
227
  }
217
228
 
@@ -197,3 +197,64 @@ describe("Tree", () => {
197
197
  await runA11yCheck();
198
198
  });
199
199
  });
200
+
201
+ describe("Tree CSS-class rendering (Tailwind migration)", () => {
202
+ it("renders Seeds CSS classes on the root and item elements", () => {
203
+ renderBasicTree();
204
+
205
+ const root = screen.getByRole("tree", { name: "Files" });
206
+ expect(root).toHaveClass("seeds-tree");
207
+
208
+ const srcItem = screen.getByRole("treeitem", { name: /src/ });
209
+ expect(srcItem).toHaveClass("seeds-tree-item");
210
+
211
+ const row = srcItem.querySelector(".seeds-tree-item-row");
212
+ expect(row).not.toBeNull();
213
+ expect(srcItem.querySelector(".seeds-tree-item-label")).not.toBeNull();
214
+
215
+ // Expanded branches expose a chevron button with the `expanded` modifier.
216
+ const chevron = row!.querySelector(".seeds-tree-item-chevron");
217
+ expect(chevron).toHaveClass("expanded");
218
+ });
219
+
220
+ it("merges a consumer className onto the root without dropping seeds-tree", () => {
221
+ renderBasicTree({ className: "my-tree" });
222
+ const root = screen.getByRole("tree", { name: "Files" });
223
+ expect(root).toHaveClass("seeds-tree");
224
+ expect(root).toHaveClass("my-tree");
225
+ });
226
+
227
+ it("sets the --tree-level custom property per depth for indentation", () => {
228
+ renderBasicTree();
229
+ const rootRow = screen
230
+ .getByRole("treeitem", { name: /src/ })
231
+ .querySelector<HTMLElement>(".seeds-tree-item-row");
232
+ const childRow = screen
233
+ .getByRole("treeitem", { name: /Button\.tsx/ })
234
+ .querySelector<HTMLElement>(".seeds-tree-item-row");
235
+ expect(rootRow!.style.getPropertyValue("--tree-level")).toBe("1");
236
+ expect(childRow!.style.getPropertyValue("--tree-level")).toBe("2");
237
+ });
238
+
239
+ it("applies the `selected` modifier class on a selected row", async () => {
240
+ const { user } = render(
241
+ <Tree aria-label="Files" selectionMode="single">
242
+ <TreeItem id="a" label="A" />
243
+ </Tree>
244
+ );
245
+ const a = screen.getByRole("treeitem", { name: "A" });
246
+ a.focus();
247
+ await user.keyboard("{Enter}");
248
+ expect(a.querySelector(".seeds-tree-item-row")).toHaveClass("selected");
249
+ });
250
+
251
+ it("applies the `disabled` modifier class on a disabled row", () => {
252
+ render(
253
+ <Tree aria-label="Files">
254
+ <TreeItem id="a" label="A" disabled />
255
+ </Tree>
256
+ );
257
+ const a = screen.getByRole("treeitem", { name: "A" });
258
+ expect(a.querySelector(".seeds-tree-item-row")).toHaveClass("disabled");
259
+ });
260
+ });
package/src/cn.ts ADDED
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Local className merge helper.
3
+ *
4
+ * `cn` is intentionally NOT imported from `@sproutsocial/seeds-react-utilities`
5
+ * (it isn't exported there); each Tailwind/CSS-class Seeds component keeps its
6
+ * own copy, matching the `seeds-react-button` reference.
7
+ */
8
+ export function cn(
9
+ ...inputs: (string | undefined | null | false | Record<string, boolean>)[]
10
+ ): string {
11
+ const classes: string[] = [];
12
+
13
+ for (const input of inputs) {
14
+ if (!input) continue;
15
+
16
+ if (typeof input === "string") {
17
+ classes.push(input);
18
+ } else if (typeof input === "object") {
19
+ for (const [key, value] of Object.entries(input)) {
20
+ if (value) {
21
+ classes.push(key);
22
+ }
23
+ }
24
+ }
25
+ }
26
+
27
+ return classes.join(" ");
28
+ }
package/src/tree.css ADDED
@@ -0,0 +1,318 @@
1
+ /**
2
+ * Seeds Tree component classes
3
+ * CSS-class mirror of the styled-components that previously styled the tree
4
+ * package (TreeStyles.tsx, TreeCombobox.tsx, Common/TreeSearchableSelectBase.tsx).
5
+ *
6
+ * Requires @sproutsocial/seeds-react-theme/dist/theme-all.css imported for
7
+ * CSS variable definitions and dark mode support.
8
+ *
9
+ * Rules live in @layer components so consumer Tailwind utilities (which sit in
10
+ * the later `utilities` layer) can override component styles on equal
11
+ * specificity. Without the layer, these unlayered rules would out-rank any
12
+ * utility class.
13
+ *
14
+ * Usage:
15
+ * <ul class="seeds-tree" role="tree">
16
+ * <li class="seeds-tree-item" role="treeitem">
17
+ * <div class="seeds-tree-item-row" style="--tree-level: 1">…</div>
18
+ * </li>
19
+ * </ul>
20
+ */
21
+
22
+ @layer components {
23
+ /* ---------------------------------------------------------------------- */
24
+ /* Tree / TreeItem (mirror of TreeStyles.tsx) */
25
+ /* ---------------------------------------------------------------------- */
26
+
27
+ .seeds-tree {
28
+ list-style: none;
29
+ margin: 0;
30
+ padding: 0;
31
+ font-family: var(--font-family);
32
+ font-size: var(--font-size-200);
33
+ line-height: var(--line-height-200);
34
+ color: var(--color-text-body);
35
+ }
36
+
37
+ .seeds-tree-item {
38
+ list-style: none;
39
+ outline: none;
40
+ }
41
+
42
+ /*
43
+ * Tree rows stack tightly, so the standard outset Seeds focusRing bleeds
44
+ * into adjacent rows. Use an inset outline so the ring is drawn just inside
45
+ * the row's edge and never overlaps siblings or children.
46
+ *
47
+ * The same ring is drawn when [data-treeitem-active] is set so combobox
48
+ * hosts that drive the tree via aria-activedescendant (no real DOM focus)
49
+ * still get a visible "active" indicator on the row.
50
+ */
51
+ .seeds-tree-item:focus-visible > .seeds-tree-item-row,
52
+ .seeds-tree-item[data-treeitem-active] > .seeds-tree-item-row {
53
+ outline: 2px solid var(--color-button-primary-bg-base);
54
+ outline-offset: -2px;
55
+ }
56
+
57
+ .seeds-tree-item-row {
58
+ display: flex;
59
+ align-items: center;
60
+ gap: var(--space-300);
61
+ padding: var(--space-300);
62
+ /* Indentation per depth: --tree-level is set inline on each row. */
63
+ padding-left: calc(
64
+ var(--space-300) + (var(--tree-level) - 1) * var(--space-400)
65
+ );
66
+ border-radius: var(--radius-400);
67
+ cursor: pointer;
68
+ user-select: none;
69
+ background: transparent;
70
+ transition: background-color var(--duration-fast) var(--ease-ease_in);
71
+ }
72
+
73
+ .seeds-tree-item-row:hover {
74
+ background: var(--color-listItem-bg-hover);
75
+ }
76
+
77
+ .seeds-tree-item-row.selected {
78
+ background: var(--color-listItem-bg-hover);
79
+ font-weight: var(--font-weight-semibold);
80
+ }
81
+
82
+ .seeds-tree-item-row.disabled {
83
+ opacity: 0.4;
84
+ cursor: not-allowed;
85
+ pointer-events: none;
86
+ }
87
+
88
+ .seeds-tree-item-group {
89
+ list-style: none;
90
+ margin: 0;
91
+ padding: 0;
92
+ }
93
+
94
+ .seeds-tree-item-label {
95
+ flex: 1;
96
+ min-width: 0;
97
+ overflow: hidden;
98
+ text-overflow: ellipsis;
99
+ white-space: nowrap;
100
+ }
101
+
102
+ .seeds-tree-item-icon {
103
+ display: inline-flex;
104
+ align-items: center;
105
+ flex-shrink: 0;
106
+ }
107
+
108
+ .seeds-tree-item-chevron {
109
+ display: inline-flex;
110
+ align-items: center;
111
+ justify-content: center;
112
+ flex-shrink: 0;
113
+ width: var(--space-500);
114
+ height: var(--space-500);
115
+ padding: 0;
116
+ border: none;
117
+ background: transparent;
118
+ color: var(--color-icon-base);
119
+ cursor: pointer;
120
+ border-radius: var(--radius-300);
121
+ transition: transform var(--duration-fast) var(--ease-ease_in);
122
+ transform: rotate(-90deg);
123
+ }
124
+
125
+ .seeds-tree-item-chevron.expanded {
126
+ transform: rotate(0deg);
127
+ }
128
+
129
+ .seeds-tree-item-chevron:hover {
130
+ background: var(--color-listItem-bg-hover);
131
+ }
132
+
133
+ /* ---------------------------------------------------------------------- */
134
+ /* TreeCombobox (mirror of TreeCombobox.tsx) */
135
+ /* ---------------------------------------------------------------------- */
136
+
137
+ .seeds-tree-combobox {
138
+ display: flex;
139
+ flex-direction: column;
140
+ flex: 1;
141
+ min-height: 0;
142
+ }
143
+
144
+ .seeds-tree-combobox-search {
145
+ padding: var(--space-300);
146
+ border-bottom: 1px solid var(--color-container-border-base);
147
+ flex-shrink: 0;
148
+ }
149
+
150
+ .seeds-tree-combobox-input-group {
151
+ display: flex;
152
+ align-items: center;
153
+ gap: var(--space-200);
154
+ padding: var(--space-200) var(--space-300);
155
+ border-radius: var(--radius-500);
156
+ border: 1px solid var(--color-container-border-base);
157
+ background: var(--color-container-bg-base);
158
+ color: var(--color-icon-base);
159
+ }
160
+
161
+ .seeds-tree-combobox-input-group:focus-within {
162
+ box-shadow:
163
+ 0 0 0 1px var(--color-button-primary-bg-base),
164
+ 0 0px 0px 4px
165
+ color-mix(
166
+ in srgb,
167
+ var(--color-button-primary-bg-base),
168
+ transparent 70%
169
+ );
170
+ outline: none;
171
+ }
172
+
173
+ .seeds-tree-combobox-input {
174
+ flex: 1;
175
+ border: none;
176
+ background: transparent;
177
+ outline: none;
178
+ font-family: var(--font-family);
179
+ font-size: var(--font-size-200);
180
+ line-height: var(--line-height-200);
181
+ color: var(--color-text-body);
182
+ }
183
+
184
+ .seeds-tree-combobox-input::placeholder {
185
+ color: var(--color-text-subtext);
186
+ }
187
+
188
+ .seeds-tree-combobox-list {
189
+ padding: var(--space-200);
190
+ overflow: auto;
191
+ flex: 1;
192
+ min-height: 0;
193
+ }
194
+
195
+ .seeds-tree-combobox-empty {
196
+ padding: var(--space-300) var(--space-350);
197
+ text-align: center;
198
+ color: var(--color-text-subtext);
199
+ font-size: var(--font-size-200);
200
+ line-height: var(--line-height-200);
201
+ }
202
+
203
+ /* ---------------------------------------------------------------------- */
204
+ /* TreeSearchableSelect (mirror of Common/TreeSearchableSelectBase.tsx) */
205
+ /* ---------------------------------------------------------------------- */
206
+
207
+ .seeds-tree-select-trigger {
208
+ display: flex;
209
+ align-items: center;
210
+ justify-content: space-between;
211
+ gap: var(--space-300);
212
+ padding: var(--space-200) var(--space-350);
213
+ min-width: 160px;
214
+ width: auto;
215
+ border-radius: var(--radius-500);
216
+ border: 1px solid var(--color-form-border-base);
217
+ background: var(--color-form-bg-base);
218
+ font-family: var(--font-family);
219
+ font-size: var(--font-size-200);
220
+ line-height: var(--line-height-200);
221
+ color: var(--color-text-body);
222
+ text-align: left;
223
+ cursor: default;
224
+ outline: none;
225
+ user-select: none;
226
+ transition:
227
+ border-color var(--duration-fast) var(--ease-ease_in),
228
+ box-shadow var(--duration-fast) var(--ease-ease_in);
229
+ }
230
+
231
+ .seeds-tree-select-trigger.full-width {
232
+ width: 100%;
233
+ }
234
+
235
+ .seeds-tree-select-trigger:focus-visible {
236
+ box-shadow:
237
+ 0 0 0 1px var(--color-button-primary-bg-base),
238
+ 0 0px 0px 4px
239
+ color-mix(
240
+ in srgb,
241
+ var(--color-button-primary-bg-base),
242
+ transparent 70%
243
+ );
244
+ outline: none;
245
+ }
246
+
247
+ .seeds-tree-select-trigger::-moz-focus-inner {
248
+ border: 0;
249
+ }
250
+
251
+ .seeds-tree-select-trigger[aria-disabled="true"],
252
+ .seeds-tree-select-trigger:disabled {
253
+ opacity: 0.4;
254
+ cursor: not-allowed;
255
+ }
256
+
257
+ .seeds-tree-select-trigger.invalid {
258
+ border-color: var(--color-form-border-error);
259
+ }
260
+
261
+ .seeds-tree-select-trigger-label {
262
+ flex: 1;
263
+ min-width: 0;
264
+ overflow: hidden;
265
+ text-overflow: ellipsis;
266
+ white-space: nowrap;
267
+ }
268
+
269
+ .seeds-tree-select-placeholder {
270
+ color: var(--color-text-subtext);
271
+ }
272
+
273
+ .seeds-tree-select-chevron {
274
+ display: inline-flex;
275
+ align-items: center;
276
+ color: var(--color-icon-base);
277
+ transition: transform var(--duration-fast) var(--ease-ease_in);
278
+ }
279
+
280
+ .seeds-tree-select-chevron.open {
281
+ transform: rotate(-180deg);
282
+ }
283
+
284
+ /*
285
+ * Visually-hidden input that mirrors the combobox's selected value into a
286
+ * form-submittable element. Matches Base UI's pattern (used by v3
287
+ * SearchableSelect): the input stays in the DOM so HTML forms pick up the
288
+ * value, but it's removed from view and from the accessibility tree.
289
+ */
290
+ .seeds-tree-select-hidden-input {
291
+ clip-path: inset(50%);
292
+ overflow: hidden;
293
+ white-space: nowrap;
294
+ border: 0;
295
+ padding: 0;
296
+ width: 1px;
297
+ height: 1px;
298
+ margin: -1px;
299
+ position: fixed;
300
+ top: 0;
301
+ left: 0;
302
+ }
303
+
304
+ .seeds-tree-select-popout {
305
+ display: flex;
306
+ flex-direction: column;
307
+ /* Width sizes to content within bounds: at least the trigger width, never */
308
+ /* wider than the available viewport space. Shallow popups stay flush with */
309
+ /* the trigger; deeply nested trees expand horizontally as needed. */
310
+ min-width: var(--radix-popover-trigger-width);
311
+ max-width: var(--radix-popper-available-width);
312
+ /* Height caps at 400px (or available viewport) so a popup with many */
313
+ /* expanded branches scrolls vertically instead of growing off-screen. */
314
+ max-height: min(var(--radix-popper-available-height), 400px);
315
+ /* Width is set inline via --tree-select-width (defaults to max-content). */
316
+ width: var(--tree-select-width, max-content);
317
+ }
318
+ }