@sproutsocial/seeds-react-tree 0.3.1 → 0.4.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.
@@ -0,0 +1,383 @@
1
+ import * as React from "react";
2
+ import styled, { css } from "styled-components";
3
+ import { Icon } from "@sproutsocial/seeds-react-icon";
4
+ import { focusRing } from "@sproutsocial/seeds-react-mixins";
5
+ import { Popout } from "@sproutsocial/seeds-react-popout/v2";
6
+ import { TreeCombobox } from "../TreeCombobox";
7
+ import { flattenTreeItems } from "./flattenTree";
8
+ import type {
9
+ TreeItemData,
10
+ TreeSelectableNodes,
11
+ TreeSelectionIndicator,
12
+ TreeSelectionMode,
13
+ } from "./types";
14
+
15
+ const invalidStyles = css`
16
+ border-color: ${({ theme }) => theme.colors.form.border.error};
17
+ `;
18
+
19
+ const Trigger = styled.button<{ $isInvalid?: boolean; $fullWidth: boolean }>`
20
+ display: flex;
21
+ align-items: center;
22
+ justify-content: space-between;
23
+ gap: ${({ theme }) => theme.space[300]};
24
+ padding: ${({ theme }) => theme.space[200]} ${({ theme }) => theme.space[350]};
25
+ min-width: 160px;
26
+ width: ${({ $fullWidth }) => ($fullWidth ? "100%" : "auto")};
27
+ border-radius: ${({ theme }) => theme.radii[500]};
28
+ border: 1px solid ${({ theme }) => theme.colors.form.border.base};
29
+ background: ${({ theme }) => theme.colors.form.background.base};
30
+ font-family: ${({ theme }) => theme.fontFamily};
31
+ font-size: ${({ theme }) => theme.typography[200].fontSize};
32
+ line-height: ${({ theme }) => theme.typography[200].lineHeight};
33
+ color: ${({ theme }) => theme.colors.text.body};
34
+ text-align: left;
35
+ cursor: default;
36
+ outline: none;
37
+ user-select: none;
38
+ transition: border-color ${({ theme }) => theme.duration.fast}
39
+ ${({ theme }) => theme.easing.ease_in},
40
+ box-shadow ${({ theme }) => theme.duration.fast}
41
+ ${({ theme }) => theme.easing.ease_in};
42
+
43
+ &:focus-visible {
44
+ ${focusRing}
45
+ }
46
+
47
+ &[aria-disabled="true"],
48
+ &:disabled {
49
+ opacity: 0.4;
50
+ cursor: not-allowed;
51
+ }
52
+
53
+ ${({ $isInvalid }) => $isInvalid && invalidStyles}
54
+ `;
55
+
56
+ const TriggerLabel = styled.span`
57
+ flex: 1;
58
+ min-width: 0;
59
+ overflow: hidden;
60
+ text-overflow: ellipsis;
61
+ white-space: nowrap;
62
+ `;
63
+
64
+ const PlaceholderText = styled.span`
65
+ color: ${({ theme }) => theme.colors.text.subtext};
66
+ `;
67
+
68
+ const Chevron = styled.span<{ $open: boolean }>`
69
+ display: inline-flex;
70
+ align-items: center;
71
+ color: ${({ theme }) => theme.colors.icon.base};
72
+ transition: transform ${({ theme }) => theme.duration.fast}
73
+ ${({ theme }) => theme.easing.ease_in};
74
+ ${({ $open }) =>
75
+ $open &&
76
+ css`
77
+ transform: rotate(-180deg);
78
+ `}
79
+ `;
80
+
81
+ /*
82
+ * Visually-hidden input that mirrors the combobox's selected value into a
83
+ * form-submittable element. Matches Base UI's pattern (used by v3
84
+ * SearchableSelect): the input stays in the DOM so HTML forms pick up the
85
+ * value, but it's removed from view and from the accessibility tree.
86
+ */
87
+ const HiddenInput = styled.input`
88
+ clip-path: inset(50%);
89
+ overflow: hidden;
90
+ white-space: nowrap;
91
+ border: 0;
92
+ padding: 0;
93
+ width: 1px;
94
+ height: 1px;
95
+ margin: -1px;
96
+ position: fixed;
97
+ top: 0;
98
+ left: 0;
99
+ `;
100
+
101
+ const PopoutContent = styled.div<{ $width?: number | string }>`
102
+ display: flex;
103
+ flex-direction: column;
104
+ /* Width sizes to content within bounds: at least the trigger width, never */
105
+ /* wider than the available viewport space. Shallow popups stay flush with */
106
+ /* the trigger; deeply nested trees expand horizontally as needed. */
107
+ min-width: var(--radix-popover-trigger-width);
108
+ max-width: var(--radix-popper-available-width);
109
+ /* Height caps at 400px (or available viewport) so a popup with many */
110
+ /* expanded branches scrolls vertically instead of growing off-screen. */
111
+ max-height: min(var(--radix-popper-available-height), 400px);
112
+ width: ${({ $width }) =>
113
+ $width === undefined
114
+ ? "max-content"
115
+ : typeof $width === "number"
116
+ ? `${$width}px`
117
+ : $width};
118
+ `;
119
+
120
+ const MAX_INLINE_LABELS = 3;
121
+
122
+ export type RenderTrigger = (selected: TreeItemData[]) => React.ReactNode;
123
+
124
+ type TreeSearchableSelectBaseProps = {
125
+ items: ReadonlyArray<TreeItemData>;
126
+
127
+ selectionMode: Exclude<TreeSelectionMode, "none">;
128
+ selectableNodes?: TreeSelectableNodes;
129
+ /** Selected ids in the underlying Tree's array form. */
130
+ selectedIds: ReadonlyArray<string>;
131
+ /** Fires with the next selected ids whenever Tree's onSelectionChange fires. */
132
+ onSelectedIdsChange: (ids: string[]) => void;
133
+
134
+ /** Accessible name for the trigger. Required (label or labelledby). */
135
+ "aria-label"?: string;
136
+ "aria-labelledby"?: string;
137
+ "aria-describedby"?: string;
138
+
139
+ /** Trigger placeholder when nothing is selected. */
140
+ placeholder?: string;
141
+ /** Search input placeholder inside the popout. */
142
+ searchPlaceholder?: string;
143
+ /** Empty-state text shown inside the popout when the query has no matches. */
144
+ emptyText?: string;
145
+
146
+ /** Render override for the trigger label area. Receives resolved selected items. */
147
+ renderTrigger?: RenderTrigger;
148
+
149
+ /** Open state — controllable. */
150
+ open?: boolean;
151
+ defaultOpen?: boolean;
152
+ onOpenChange?: (open: boolean) => void;
153
+
154
+ /** Close the popout immediately after a selection change. Single-select uses this. */
155
+ closeOnSelect: boolean;
156
+
157
+ disabled?: boolean;
158
+ isInvalid?: boolean;
159
+ required?: boolean;
160
+ readOnly?: boolean;
161
+ /**
162
+ * Name for the hidden form input. When set, the component participates in
163
+ * native HTML form submission. For multi-select, one `<input type="hidden">`
164
+ * is rendered per selected id under this name (so `formData.getAll(name)`
165
+ * returns every selection).
166
+ */
167
+ name?: string;
168
+ fullWidth?: boolean;
169
+
170
+ /**
171
+ * Popout content width. Defaults to the trigger's width (via Radix's
172
+ * `--radix-popover-trigger-width`). Pass a number (px) or any valid CSS
173
+ * width string to override.
174
+ */
175
+ contentWidth?: number | string;
176
+
177
+ /** Tree props passed through. */
178
+ renderSelectionIndicator?: TreeSelectionIndicator;
179
+ defaultExpanded?: ReadonlyArray<string>;
180
+ expanded?: ReadonlyArray<string>;
181
+ onExpandedChange?: (expanded: string[]) => void;
182
+
183
+ id?: string;
184
+ className?: string;
185
+ };
186
+
187
+ function defaultRenderTrigger(
188
+ selected: TreeItemData[],
189
+ placeholder: string
190
+ ): React.ReactNode {
191
+ if (selected.length === 0) {
192
+ return <PlaceholderText>{placeholder}</PlaceholderText>;
193
+ }
194
+ if (selected.length <= MAX_INLINE_LABELS) {
195
+ return selected.map((s) => s.label).join(", ");
196
+ }
197
+ return `${selected.length} selected`;
198
+ }
199
+
200
+ export function TreeSearchableSelectBase(props: TreeSearchableSelectBaseProps) {
201
+ const {
202
+ items,
203
+ selectionMode,
204
+ selectableNodes,
205
+ selectedIds,
206
+ onSelectedIdsChange,
207
+ placeholder = "Select...",
208
+ searchPlaceholder = "Search...",
209
+ emptyText = "No results found.",
210
+ renderTrigger,
211
+ open: openProp,
212
+ defaultOpen = false,
213
+ onOpenChange,
214
+ closeOnSelect,
215
+ disabled,
216
+ isInvalid,
217
+ required,
218
+ readOnly,
219
+ name,
220
+ fullWidth = true,
221
+ contentWidth,
222
+ renderSelectionIndicator,
223
+ defaultExpanded,
224
+ expanded,
225
+ onExpandedChange,
226
+ id,
227
+ className,
228
+ } = props;
229
+ const ariaLabel = props["aria-label"];
230
+ const ariaLabelledBy = props["aria-labelledby"];
231
+ const ariaDescribedBy = props["aria-describedby"];
232
+
233
+ const isOpenControlled = openProp !== undefined;
234
+ const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen);
235
+ const open = isOpenControlled ? !!openProp : uncontrolledOpen;
236
+ const setOpen = (next: boolean) => {
237
+ if (!isOpenControlled) setUncontrolledOpen(next);
238
+ onOpenChange?.(next);
239
+ };
240
+
241
+ // Persist the search query across open/close so users don't lose their
242
+ // place after picking a multi-select item.
243
+ const [query, setQuery] = React.useState("");
244
+
245
+ // Stable id for the popup so the trigger's `aria-controls` resolves to a
246
+ // real DOM element when open. Colons from React.useId() get stripped — axe
247
+ // rejects them inside aria-* attribute values.
248
+ const reactId = React.useId();
249
+ const popupId = `tree-select-popup-${reactId.replace(/:/g, "")}`;
250
+
251
+ // Resolve selected ids to data once per change.
252
+ const flat = React.useMemo(() => flattenTreeItems(items), [items]);
253
+ const selectedItems = React.useMemo(() => {
254
+ const order = new Map(flat.map((it, i) => [it.id, i] as const));
255
+ return selectedIds
256
+ .map((id) => flat.find((it) => it.id === id))
257
+ .filter((it): it is TreeItemData => it != null)
258
+ .sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0));
259
+ }, [flat, selectedIds]);
260
+
261
+ const handleSelectionChange = (next: string[]) => {
262
+ onSelectedIdsChange(next);
263
+ if (closeOnSelect) {
264
+ setOpen(false);
265
+ }
266
+ };
267
+
268
+ const triggerContent = renderTrigger
269
+ ? renderTrigger(selectedItems)
270
+ : defaultRenderTrigger(selectedItems, placeholder);
271
+
272
+ // Hidden input mirrors Base UI's pattern (see v3 SearchableSelect):
273
+ // - Single-select: the primary input carries the value + name, so the
274
+ // form picks it up like any text input.
275
+ // - Multi-select: the primary input has no name (doesn't submit) and a
276
+ // `<input type="hidden">` is rendered per selected id so
277
+ // `formData.getAll(name)` returns every selection.
278
+ // When no `name` is provided, only an id-based primary input is rendered
279
+ // so the `FormField → input` association keeps working.
280
+ const isMulti = selectionMode === "multiple";
281
+ const primaryInputName = isMulti ? undefined : name;
282
+ const primaryInputId =
283
+ id && primaryInputName == null ? `${id}-hidden-input` : undefined;
284
+
285
+ return (
286
+ <>
287
+ <HiddenInput
288
+ type="text"
289
+ id={primaryInputId}
290
+ name={primaryInputName}
291
+ value={isMulti ? "" : selectedIds[0] ?? ""}
292
+ onChange={() => {
293
+ // Controlled value with no user-driven mutation — value updates come
294
+ // from the combobox's onSelectedIdsChange path, not from the input.
295
+ }}
296
+ disabled={disabled}
297
+ required={required && !isMulti}
298
+ readOnly={readOnly}
299
+ tabIndex={-1}
300
+ aria-hidden="true"
301
+ />
302
+ {isMulti && name
303
+ ? selectedIds.map((selectedId) => (
304
+ <input
305
+ key={selectedId}
306
+ type="hidden"
307
+ name={name}
308
+ value={selectedId}
309
+ />
310
+ ))
311
+ : null}
312
+ <Popout
313
+ open={open}
314
+ onOpenChange={setOpen}
315
+ side="bottom"
316
+ align="start"
317
+ // Popout v2's default has padding: space[400] around its content. Our
318
+ // inner sections (search header + tree list) own their padding, so we
319
+ // strip the outer padding here to match the v3 SearchableSelect popup.
320
+ style={{ padding: 0 }}
321
+ onEscapeKeyDown={(event) => {
322
+ // When the user has a query typed, the first Escape should clear the
323
+ // query (handled inside TreeCombobox) and leave the popout open.
324
+ // The second Escape (with empty query) closes it.
325
+ if (query.length > 0) {
326
+ event.preventDefault();
327
+ }
328
+ }}
329
+ content={
330
+ <PopoutContent id={popupId} $width={contentWidth}>
331
+ <TreeCombobox
332
+ /*
333
+ * The wrapper's `aria-label` / `aria-labelledby` already names the
334
+ * outer trigger combobox. Giving the inner search input the same
335
+ * name produces two `role="combobox"` elements with identical
336
+ * accessible names — confusing for AT and ambiguous for tests.
337
+ * The inner input is the "search-within-the-popup" control, so
338
+ * give it a distinct, generic label.
339
+ */
340
+ aria-label="Search"
341
+ items={items}
342
+ placeholder={searchPlaceholder}
343
+ emptyText={emptyText}
344
+ query={query}
345
+ onQueryChange={setQuery}
346
+ selectionMode={selectionMode}
347
+ selectableNodes={selectableNodes}
348
+ selected={selectedIds}
349
+ onSelectionChange={handleSelectionChange}
350
+ renderSelectionIndicator={renderSelectionIndicator}
351
+ defaultExpanded={defaultExpanded}
352
+ expanded={expanded}
353
+ onExpandedChange={onExpandedChange}
354
+ />
355
+ </PopoutContent>
356
+ }
357
+ >
358
+ <Trigger
359
+ type="button"
360
+ id={id}
361
+ className={className}
362
+ $isInvalid={isInvalid}
363
+ $fullWidth={fullWidth}
364
+ disabled={disabled}
365
+ role="combobox"
366
+ aria-haspopup="dialog"
367
+ aria-expanded={open}
368
+ aria-controls={open ? popupId : undefined}
369
+ aria-label={ariaLabel}
370
+ aria-labelledby={ariaLabelledBy}
371
+ aria-describedby={ariaDescribedBy}
372
+ aria-invalid={isInvalid || undefined}
373
+ aria-required={required || undefined}
374
+ >
375
+ <TriggerLabel>{triggerContent}</TriggerLabel>
376
+ <Chevron $open={open} aria-hidden>
377
+ <Icon name="chevron-down-outline" size="small" />
378
+ </Chevron>
379
+ </Trigger>
380
+ </Popout>
381
+ </>
382
+ );
383
+ }
@@ -0,0 +1,22 @@
1
+ import type { TreeItemData } from "./types";
2
+
3
+ /**
4
+ * Walk a tree depth-first and return every item in a flat array, preserving
5
+ * the original visit order.
6
+ *
7
+ * Used by `TreeSearchableSelect` wrappers to resolve selected ids back to
8
+ * their `TreeItemData` for rendering trigger labels.
9
+ */
10
+ export function flattenTreeItems(
11
+ items: ReadonlyArray<TreeItemData>
12
+ ): TreeItemData[] {
13
+ const out: TreeItemData[] = [];
14
+ const walk = (nodes: ReadonlyArray<TreeItemData>) => {
15
+ for (const node of nodes) {
16
+ out.push(node);
17
+ if (node.children) walk(node.children);
18
+ }
19
+ };
20
+ walk(items);
21
+ return out;
22
+ }
@@ -0,0 +1,256 @@
1
+ import React from "react";
2
+ import styled from "styled-components";
3
+ import type { Meta, StoryObj } from "@storybook/react";
4
+ import { MultiTreeSearchableSelect } from "./MultiTreeSearchableSelect";
5
+ import {
6
+ collections,
7
+ fileTree,
8
+ pokedex,
9
+ checkboxIndicator,
10
+ SelectionReadout,
11
+ } from "./storyData";
12
+
13
+ const meta: Meta<typeof MultiTreeSearchableSelect> = {
14
+ title: "Really Under Development/Tree/MultiTreeSearchableSelect",
15
+ component: MultiTreeSearchableSelect,
16
+ };
17
+
18
+ export default meta;
19
+ type Story = StoryObj<typeof MultiTreeSearchableSelect>;
20
+
21
+ const Stage = styled.div`
22
+ padding: ${({ theme }) => theme.space[500]};
23
+ `;
24
+
25
+ const Field = styled.div`
26
+ width: 300px;
27
+ `;
28
+
29
+ const TwoColumn = styled.div`
30
+ display: grid;
31
+ grid-template-columns: 1fr 1fr;
32
+ gap: ${({ theme }) => theme.space[500]};
33
+ align-items: start;
34
+ max-width: 640px;
35
+ `;
36
+
37
+ const FieldLabel = styled.div`
38
+ ${({ theme }) => theme.typography[200]}
39
+ font-weight: ${({ theme }) => theme.fontWeights.semibold};
40
+ margin-bottom: ${({ theme }) => theme.space[200]};
41
+ `;
42
+
43
+ export const Basic: Story = {
44
+ name: "Basic",
45
+ render: () => (
46
+ <Stage>
47
+ <Field>
48
+ <MultiTreeSearchableSelect
49
+ aria-label="Pick collections"
50
+ items={collections}
51
+ placeholder="Pick collections"
52
+ renderSelectionIndicator={checkboxIndicator}
53
+ defaultExpanded={["customer-care"]}
54
+ />
55
+ </Field>
56
+ </Stage>
57
+ ),
58
+ };
59
+
60
+ export const Preselected: Story = {
61
+ name: "Pre-selected (uncontrolled default)",
62
+ render: () => (
63
+ <Stage>
64
+ <Field>
65
+ <MultiTreeSearchableSelect
66
+ aria-label="Pick collections"
67
+ items={collections}
68
+ placeholder="Pick collections"
69
+ renderSelectionIndicator={checkboxIndicator}
70
+ defaultExpanded={["customer-care"]}
71
+ defaultSelectedItemIds={["questions", "bug-reports"]}
72
+ />
73
+ </Field>
74
+ </Stage>
75
+ ),
76
+ };
77
+
78
+ export const Controlled: Story = {
79
+ name: "Controlled selection",
80
+ render: () => {
81
+ const [selected, setSelected] = React.useState<string[]>([
82
+ "questions",
83
+ "bug-reports",
84
+ ]);
85
+ return (
86
+ <Stage>
87
+ <Field>
88
+ <MultiTreeSearchableSelect
89
+ aria-label="Pick collections"
90
+ items={collections}
91
+ placeholder="Pick collections"
92
+ renderSelectionIndicator={checkboxIndicator}
93
+ defaultExpanded={["customer-care"]}
94
+ selectedItemIds={selected}
95
+ onSelectedItemIdsChange={setSelected}
96
+ />
97
+ <SelectionReadout selected={selected} />
98
+ </Field>
99
+ </Stage>
100
+ );
101
+ },
102
+ };
103
+
104
+ export const ManySelectionsCollapsesToCount: Story = {
105
+ name: "Many selections (N selected)",
106
+ render: () => {
107
+ const [selected, setSelected] = React.useState<string[]>([
108
+ "questions",
109
+ "bug-reports",
110
+ "feature-requests",
111
+ "complaints",
112
+ ]);
113
+ return (
114
+ <Stage>
115
+ <Field>
116
+ <MultiTreeSearchableSelect
117
+ aria-label="Pick collections"
118
+ items={collections}
119
+ placeholder="Pick collections"
120
+ renderSelectionIndicator={checkboxIndicator}
121
+ defaultExpanded={["customer-care"]}
122
+ selectedItemIds={selected}
123
+ onSelectedItemIdsChange={setSelected}
124
+ />
125
+ <SelectionReadout selected={selected} />
126
+ </Field>
127
+ </Stage>
128
+ );
129
+ },
130
+ };
131
+
132
+ export const LeavesOnly: Story = {
133
+ name: "Leaves-only selection",
134
+ render: () => (
135
+ <Stage>
136
+ <Field>
137
+ <MultiTreeSearchableSelect
138
+ aria-label="Pick files"
139
+ items={fileTree}
140
+ placeholder="Pick files"
141
+ selectableNodes="leaves"
142
+ renderSelectionIndicator={checkboxIndicator}
143
+ />
144
+ </Field>
145
+ </Stage>
146
+ ),
147
+ };
148
+
149
+ export const FullWidth: Story = {
150
+ name: "Full width",
151
+ render: () => (
152
+ <Stage>
153
+ <div style={{ width: 600 }}>
154
+ <MultiTreeSearchableSelect
155
+ aria-label="Pick collections"
156
+ items={collections}
157
+ placeholder="Pick collections"
158
+ renderSelectionIndicator={checkboxIndicator}
159
+ defaultExpanded={["customer-care"]}
160
+ fullWidth
161
+ />
162
+ </div>
163
+ </Stage>
164
+ ),
165
+ };
166
+
167
+ export const DisabledAndInvalid: Story = {
168
+ name: "Disabled & invalid states",
169
+ render: () => (
170
+ <Stage>
171
+ <TwoColumn>
172
+ <div>
173
+ <FieldLabel>Disabled</FieldLabel>
174
+ <MultiTreeSearchableSelect
175
+ aria-label="Disabled select"
176
+ items={collections}
177
+ placeholder="Pick collections"
178
+ renderSelectionIndicator={checkboxIndicator}
179
+ disabled
180
+ />
181
+ </div>
182
+ <div>
183
+ <FieldLabel>Invalid</FieldLabel>
184
+ <MultiTreeSearchableSelect
185
+ aria-label="Invalid select"
186
+ items={collections}
187
+ placeholder="Pick collections"
188
+ renderSelectionIndicator={checkboxIndicator}
189
+ isInvalid
190
+ />
191
+ </div>
192
+ </TwoColumn>
193
+ </Stage>
194
+ ),
195
+ };
196
+
197
+ export const DeepTree: Story = {
198
+ name: "Deep tree (7 levels)",
199
+ render: () => {
200
+ const [selected, setSelected] = React.useState<string[]>([]);
201
+ return (
202
+ <Stage>
203
+ <Field>
204
+ <MultiTreeSearchableSelect
205
+ aria-label="Pick Pokémon"
206
+ items={pokedex}
207
+ placeholder="Pick Pokémon"
208
+ searchPlaceholder="Search the Pokédex..."
209
+ renderSelectionIndicator={checkboxIndicator}
210
+ defaultExpanded={[
211
+ "gen-1",
212
+ "kanto",
213
+ "kanto-grass",
214
+ "bulbasaur-line",
215
+ ]}
216
+ selectedItemIds={selected}
217
+ onSelectedItemIdsChange={setSelected}
218
+ />
219
+ <SelectionReadout selected={selected} />
220
+ </Field>
221
+ </Stage>
222
+ );
223
+ },
224
+ };
225
+
226
+ export const CustomRenderTrigger: Story = {
227
+ name: "Custom renderTrigger",
228
+ render: () => {
229
+ const [selected, setSelected] = React.useState<string[]>([
230
+ "questions",
231
+ "bug-reports",
232
+ ]);
233
+ return (
234
+ <Stage>
235
+ <Field>
236
+ <MultiTreeSearchableSelect
237
+ aria-label="Pick collections"
238
+ items={collections}
239
+ placeholder="Pick collections"
240
+ renderSelectionIndicator={checkboxIndicator}
241
+ defaultExpanded={["customer-care"]}
242
+ selectedItemIds={selected}
243
+ onSelectedItemIdsChange={setSelected}
244
+ renderTrigger={(items) => (
245
+ <span>
246
+ <strong>{items.length}</strong>{" "}
247
+ {items.length === 1 ? "collection" : "collections"} picked
248
+ </span>
249
+ )}
250
+ />
251
+ <SelectionReadout selected={selected} />
252
+ </Field>
253
+ </Stage>
254
+ );
255
+ },
256
+ };