blixify-ui-web-mcp 0.1.41 → 0.1.42
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/lib/components/chat/chatAnchor/README.md +1 -1
- package/lib/components/chat/chatBase/chatBase.stories.tsx +24 -1
- package/lib/components/data/dataTemplate/README.md +1 -0
- package/lib/components/data/dataWrapper/README.md +25 -0
- package/lib/components/data/dataWrapper/dataWrapper.barcode.stories.tsx +85 -0
- package/lib/components/data/dataWrapper/dataWrapper.view.stories.tsx +1 -1
- package/lib/components/display/newPagination/README.md +5 -9
- package/lib/components/input/markdown/markdown.stories.tsx +23 -0
- package/lib/components/input/signature/signature.stories.tsx +84 -3
- package/lib/components/input/textInput/textinput.stories.tsx +29 -10
- package/lib/components/navigation/leftNav/README.md +39 -0
- package/lib/components/navigation/leftNav/leftNav.stories.tsx +252 -31
- package/package.json +1 -1
|
@@ -80,7 +80,7 @@ return (
|
|
|
80
80
|
|
|
81
81
|
| Name | Type | Default | Description |
|
|
82
82
|
| ---------------- | ----------------------------- | -------------------------------------- | ----------------------------------------------------------------------------------------------- |
|
|
83
|
-
| `positionOffset` | `string` | `"bottom-
|
|
83
|
+
| `positionOffset` | `string` | `"bottom-[88px] right-4 md:bottom-6 md:right-6"` | Tailwind classes that control the fixed position of the anchor. On phones the default rests on the top edge of a bottom bar such as DataTemplate's Quick find, clear of its filter button. Ignored on mobile when open. |
|
|
84
84
|
| `greetingCard` | `boolean` | `false` | When `true`, shows a greeting bubble next to the anchor button while the chat is closed. |
|
|
85
85
|
| `greetingText` | `string` | `"Welcome! How can I help you today?"` | Text displayed inside the greeting card bubble. |
|
|
86
86
|
| `openIcon` | `React.ReactNode` \| `string` | `ChatBubbleOvalLeftIcon` | Icon shown when the chat is closed. A string is treated as an image URL (56×56 rounded circle). |
|
|
@@ -263,6 +263,29 @@ export const ChatBase = () => {
|
|
|
263
263
|
} as any,
|
|
264
264
|
createdAt: dayjs("2023-11-03 12:02").toDate(),
|
|
265
265
|
},
|
|
266
|
+
// INFO: image attachments whose backend-stored MIME type is missing or
|
|
267
|
+
// generic — these must still preview inline (resolved from the filename
|
|
268
|
+
// extension), not fall through to the generic file card.
|
|
269
|
+
{
|
|
270
|
+
sender: botUser,
|
|
271
|
+
messageBody: {
|
|
272
|
+
fileName:
|
|
273
|
+
"https://images.unsplash.com/photo-1506744038136-46273834b3fb?w=400",
|
|
274
|
+
token: "8e050d2a-28c0-4b07-a6f3-c45b65730727.gif",
|
|
275
|
+
type: "",
|
|
276
|
+
} as any,
|
|
277
|
+
createdAt: dayjs("2023-11-03 12:03").toDate(),
|
|
278
|
+
},
|
|
279
|
+
{
|
|
280
|
+
sender: botUser,
|
|
281
|
+
messageBody: {
|
|
282
|
+
fileName:
|
|
283
|
+
"https://images.unsplash.com/photo-1465101046530-73398c7f28ca?w=400",
|
|
284
|
+
token: "generated-image.png",
|
|
285
|
+
type: "application/octet-stream",
|
|
286
|
+
} as any,
|
|
287
|
+
createdAt: dayjs("2023-11-03 12:04").toDate(),
|
|
288
|
+
},
|
|
266
289
|
]),
|
|
267
290
|
);
|
|
268
291
|
const [files4, setFiles4] = useState<File[]>([]);
|
|
@@ -594,7 +617,7 @@ export const ChatBase = () => {
|
|
|
594
617
|
|
|
595
618
|
<StorySection
|
|
596
619
|
title="File Upload"
|
|
597
|
-
description="fileUpload.allowFileUpload=true shows a paperclip button. Selected files appear as chips in footerElement. onSendMessage fires for both text and file sends — the consumer handles upload logic there."
|
|
620
|
+
description="fileUpload.allowFileUpload=true shows a paperclip button. Selected files appear as chips in footerElement. onSendMessage fires for both text and file sends — the consumer handles upload logic there. Image attachments preview inline even when the backend-stored MIME type is missing or generic (the .gif and .png below arrive with type '' / 'application/octet-stream' and are resolved from their filename extension)."
|
|
598
621
|
>
|
|
599
622
|
<ChatBaseImpl
|
|
600
623
|
open
|
|
@@ -1095,6 +1095,7 @@ Which field types can be edited from the list table, and how. Types not listed u
|
|
|
1095
1095
|
| `groupIdCollapsible` | `{ [key: string]: boolean }` | Initial collapsed/expanded state per group ID |
|
|
1096
1096
|
| `sortGroupByAlphabetically` | `boolean` | Sorts group-by values alphabetically |
|
|
1097
1097
|
| `unionCollections` | `any[]` | Additional collections to union-query alongside the main collection |
|
|
1098
|
+
| `unionPagination` | `boolean` | Opt in to paging a union list: sends the page offset as `skip` and adds `_id` to the sort as a tie-break (default `false`). See *Union Collections* in the DataWrapper README |
|
|
1098
1099
|
| `moreSettings` | `{ text: string; onClick: () => void }[]` | Extra items appended to the "more" dropdown menu |
|
|
1099
1100
|
| `exportSettings` | `ExportSettings` | See Export Settings below |
|
|
1100
1101
|
| `onChangeSort` | `(value: Sort) => void` | Called when the user changes the sort |
|
|
@@ -1017,6 +1017,31 @@ organise={{
|
|
|
1017
1017
|
|
|
1018
1018
|
---
|
|
1019
1019
|
|
|
1020
|
+
### Paging a union list — `unionPagination`
|
|
1021
|
+
|
|
1022
|
+
A union list does not page on its own. The DTW sends `skip: 0` for every union request, so page 2 (or the next scroll load) returns the first rows again. If the app fetches the whole union in one request and pages it itself, that is fine and nothing changes.
|
|
1023
|
+
|
|
1024
|
+
Set `organise.unionPagination` to let the server do the paging instead. It is **off by default**, so upgrading the library changes nothing until you opt in.
|
|
1025
|
+
|
|
1026
|
+
```tsx
|
|
1027
|
+
organise={{
|
|
1028
|
+
unionCollections: [/* ... */],
|
|
1029
|
+
unionPagination: true,
|
|
1030
|
+
paginationType: "scroll", // or "page"
|
|
1031
|
+
sort: { sortId: "sortDate", type: "asc" },
|
|
1032
|
+
}}
|
|
1033
|
+
limit={20}
|
|
1034
|
+
```
|
|
1035
|
+
|
|
1036
|
+
With it on, each page request:
|
|
1037
|
+
|
|
1038
|
+
- sends `skip = pageIndex × limit` (the first page is `0`);
|
|
1039
|
+
- sends the sort followed by `_id` in the same direction, e.g. `[{ sortId: "sortDate", type: "asc" }, { sortId: "_id", type: "asc" }]`. Every page is a separate request, and two rows with the same sort value have no fixed order between requests, so without the tie-break a row can appear on two pages or on none. With no sort set, the server default (`date` descending) is kept and `_id` is added after it.
|
|
1040
|
+
|
|
1041
|
+
It only applies with `unionCollections`. Calendar, map and timeline views fetch the full set and are not affected.
|
|
1042
|
+
|
|
1043
|
+
---
|
|
1044
|
+
|
|
1020
1045
|
### Dynamic queries — rebuild unionCollections when filters change
|
|
1021
1046
|
|
|
1022
1047
|
Because `unionCollections` is inside `organise`, you can recompute it reactively when your filter state changes. Call `dataRef.current?.handleRefresh()` after updating state.
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// INFO: a DataTemplateWrapper form with a barcodeEnabled field, on the in-memory
|
|
2
|
+
// story backend so it opens without a server. The scan button runs the real
|
|
3
|
+
// camera flow: BarcodeDetector where the browser has it, a ZXing-wasm polyfill
|
|
4
|
+
// elsewhere (iOS Safari). The camera needs https or localhost.
|
|
5
|
+
|
|
6
|
+
import React, { useState } from "react";
|
|
7
|
+
import { Loading } from "../../display/loading";
|
|
8
|
+
import { DataStructure } from "../dataTemplate/model";
|
|
9
|
+
import { DataTemplateWrapper } from "./index";
|
|
10
|
+
import {
|
|
11
|
+
handleBuildStoryRecords,
|
|
12
|
+
storyWorkspace,
|
|
13
|
+
useStoryBackend,
|
|
14
|
+
} from "./storyBackend.fixtures";
|
|
15
|
+
|
|
16
|
+
const meta = {
|
|
17
|
+
title: "Data/Data Wrapper/Barcode",
|
|
18
|
+
decorators: [
|
|
19
|
+
(Story: () => React.ReactElement) => (
|
|
20
|
+
<div className="bg-white min-h-screen p-4">
|
|
21
|
+
<Story />
|
|
22
|
+
</div>
|
|
23
|
+
),
|
|
24
|
+
],
|
|
25
|
+
};
|
|
26
|
+
export default meta;
|
|
27
|
+
|
|
28
|
+
const COLLECTION = "dtw-barcode-collection";
|
|
29
|
+
|
|
30
|
+
const BarcodeSchema: DataStructure[] = [
|
|
31
|
+
{ id: "name", name: "Asset Name", type: "string" },
|
|
32
|
+
{
|
|
33
|
+
id: "assetTag",
|
|
34
|
+
name: "Asset Tag",
|
|
35
|
+
type: "string",
|
|
36
|
+
barcodeEnabled: true,
|
|
37
|
+
capitalize: true,
|
|
38
|
+
},
|
|
39
|
+
{ id: "location", name: "Location", type: "string" },
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
export const BarcodeField = () => {
|
|
43
|
+
const mount = useStoryBackend({
|
|
44
|
+
collections: {
|
|
45
|
+
[COLLECTION]: handleBuildStoryRecords(5, (index) => ({
|
|
46
|
+
_id: `asset-${index}`,
|
|
47
|
+
name: `Asset ${index}`,
|
|
48
|
+
assetTag: `BLX-00${index}`,
|
|
49
|
+
location: `Rack ${index}`,
|
|
50
|
+
})),
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
const [viewType, setViewType] = useState<"list" | "read" | "update">(
|
|
54
|
+
"update",
|
|
55
|
+
);
|
|
56
|
+
const [selectedId, setSelectedId] = useState<string | undefined>("asset-1");
|
|
57
|
+
|
|
58
|
+
if (!mount) return <Loading />;
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<div className="w-full max-w-2xl">
|
|
62
|
+
<div className="font-semibold mb-1 text-gray-900">Barcode field</div>
|
|
63
|
+
<div className="text-sm font-light mb-4 text-gray-500">
|
|
64
|
+
Tap the scan icon on Asset Tag, then point the camera at a barcode or QR
|
|
65
|
+
code. Save runs against the in-memory story backend.
|
|
66
|
+
</div>
|
|
67
|
+
<DataTemplateWrapper
|
|
68
|
+
title="Asset"
|
|
69
|
+
type={viewType}
|
|
70
|
+
id={selectedId}
|
|
71
|
+
collectionId={COLLECTION}
|
|
72
|
+
schema={BarcodeSchema}
|
|
73
|
+
workspace={storyWorkspace()}
|
|
74
|
+
edit={{ allEditable: true, disabledUpdate: false }}
|
|
75
|
+
bareSettings={{ bareNewDTW: true, bareOffline: false }}
|
|
76
|
+
handleNavigate={(type: any, id: any) => {
|
|
77
|
+
setViewType(type);
|
|
78
|
+
setSelectedId(id);
|
|
79
|
+
}}
|
|
80
|
+
limit={10}
|
|
81
|
+
/>
|
|
82
|
+
</div>
|
|
83
|
+
);
|
|
84
|
+
};
|
|
85
|
+
BarcodeField.storyName = "Barcode Field";
|
|
@@ -4,12 +4,8 @@ The footer every new-DTW list renderer shares — NewTable, NewList and the grid
|
|
|
4
4
|
view: "Showing N result(s)" on the left, bare chevrons with an outlined current
|
|
5
5
|
page on the right. Dark mode follows `darkMode` / `darkModeBg` like the tables.
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
| `customPaginationSize` | `number` | overrides the shown count |
|
|
13
|
-
| `hidePagination` | `boolean` | renders nothing |
|
|
14
|
-
| `handleUpdatePageIndex` | `(value: number \| "prev" \| "next") => void` | page numbers for `page`, `prev` / `next` for `cursor` |
|
|
15
|
-
| `className` | `string` | extra classes on the footer row (NewTable uses it for the fixed footer) |
|
|
7
|
+
`page` paging also shows a go-to-page input — a numeric field with −/+ steppers on
|
|
8
|
+
larger screens. The page changes on its own about 0.7s after the last digit, at once when no
|
|
9
|
+
longer number could be a valid page, and straight away on Enter or blur. A typed page is
|
|
10
|
+
clamped to the first and last page, and the input is hidden when there is only one page.
|
|
11
|
+
`cursor` and `scroll` paging have no total, so they never show it.
|
|
@@ -26,6 +26,9 @@ export const MarkdownInput = () => {
|
|
|
26
26
|
const [diagramValue, setDiagramValue] = useState<string | undefined>(
|
|
27
27
|
DIAGRAM_SAMPLE,
|
|
28
28
|
);
|
|
29
|
+
const [smallValue, setSmallValue] = useState<string | undefined>(
|
|
30
|
+
"## Text size sm\n\nThis editor reads as 14px on every screen. On a phone it keeps a real 16px font and is drawn at 87.5%, so iOS Safari does not zoom in when it is tapped.\n\n- Select text to see the floating toolbar",
|
|
31
|
+
);
|
|
29
32
|
const handleOnChange = (id: string, value?: string) => setValue(value);
|
|
30
33
|
const handleOnDiagramChange = (id: string, value?: string) =>
|
|
31
34
|
setDiagramValue(value);
|
|
@@ -107,6 +110,26 @@ export const MarkdownInput = () => {
|
|
|
107
110
|
</pre>
|
|
108
111
|
</StorySection>
|
|
109
112
|
|
|
113
|
+
<StorySection
|
|
114
|
+
title="Text size sm"
|
|
115
|
+
description="textSize="sm" gives 14px text on every screen. On phones the editor keeps a real 16px font drawn with transform: scale(0.875), the same trick as TextInput xs, so iOS Safari does not zoom on focus. CSS zoom would still trigger the zoom. MarkdownStatic takes the same prop."
|
|
116
|
+
>
|
|
117
|
+
<div className="grid w-full gap-6 md:grid-cols-2">
|
|
118
|
+
<MarkdownInputImpl
|
|
119
|
+
id="text-size-sm"
|
|
120
|
+
value={smallValue ?? ""}
|
|
121
|
+
onChange={(id, value) => setSmallValue(value)}
|
|
122
|
+
borderless={true}
|
|
123
|
+
textSize="sm"
|
|
124
|
+
/>
|
|
125
|
+
<MarkdownStatic
|
|
126
|
+
label="MarkdownStatic"
|
|
127
|
+
value={smallValue ?? ""}
|
|
128
|
+
textSize="sm"
|
|
129
|
+
/>
|
|
130
|
+
</div>
|
|
131
|
+
</StorySection>
|
|
132
|
+
|
|
110
133
|
<StorySection
|
|
111
134
|
title="Mermaid diagrams"
|
|
112
135
|
description="lib.mermaid given, so ```mermaid blocks draw as diagrams — under the source while editing, in place of it in MarkdownStatic. Inline tags such as <b>, <i> and <br> format the label; an <img> is stripped."
|
|
@@ -16,10 +16,14 @@ export const Signature = () => {
|
|
|
16
16
|
const signatureRef = useRef<any>(null);
|
|
17
17
|
const modalSignatureRef = useRef<any>(null);
|
|
18
18
|
const modalFormRef = useRef<any>(null);
|
|
19
|
+
const darkSignatureRef = useRef<any>(null);
|
|
20
|
+
const lightSignatureRef = useRef<any>(null);
|
|
19
21
|
|
|
20
22
|
const [signing, setSigning] = useState(false);
|
|
21
23
|
const [modalSigning, setModalSigning] = useState(false);
|
|
22
24
|
const [signaturePreview, setSignaturePreview] = useState("");
|
|
25
|
+
const [darkPreview, setDarkPreview] = useState("");
|
|
26
|
+
const [lightPreview, setLightPreview] = useState("");
|
|
23
27
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
|
24
28
|
const [modalKey, setModalKey] = useState(0);
|
|
25
29
|
|
|
@@ -103,25 +107,102 @@ export const Signature = () => {
|
|
|
103
107
|
</div>
|
|
104
108
|
</StorySection>
|
|
105
109
|
|
|
110
|
+
<StorySection
|
|
111
|
+
title="Light mode (exported preview)"
|
|
112
|
+
description="Default pad, no penColor set. The exported image is black ink on a transparent background — the first preview sits on white, the second on a checkerboard so the transparency is visible."
|
|
113
|
+
>
|
|
114
|
+
<div className="w-full max-w-md">
|
|
115
|
+
<div className="h-[280px]">
|
|
116
|
+
<SignatureImpl
|
|
117
|
+
id="signature-light"
|
|
118
|
+
ref={lightSignatureRef}
|
|
119
|
+
signing={false}
|
|
120
|
+
label="Signature"
|
|
121
|
+
optional
|
|
122
|
+
signatureClear={true}
|
|
123
|
+
onChange={() => {}}
|
|
124
|
+
onSubmit={(_, dataURL) => setLightPreview(dataURL ?? "")}
|
|
125
|
+
/>
|
|
126
|
+
</div>
|
|
127
|
+
<Button
|
|
128
|
+
className="mt-3"
|
|
129
|
+
size="small"
|
|
130
|
+
text="Submit"
|
|
131
|
+
type="normal"
|
|
132
|
+
onClick={() => lightSignatureRef.current?.handleSubmit()}
|
|
133
|
+
/>
|
|
134
|
+
{lightPreview && (
|
|
135
|
+
<div className="mt-3 flex flex-wrap gap-3">
|
|
136
|
+
<div className="rounded bg-white p-2">
|
|
137
|
+
<p className="text-sm font-medium text-gray-700 mb-1">
|
|
138
|
+
Exported preview (on white)
|
|
139
|
+
</p>
|
|
140
|
+
<img
|
|
141
|
+
src={lightPreview}
|
|
142
|
+
alt="light mode signature preview on white"
|
|
143
|
+
className="border rounded max-h-24"
|
|
144
|
+
/>
|
|
145
|
+
</div>
|
|
146
|
+
<div className="rounded bg-white p-2">
|
|
147
|
+
<p className="text-sm font-medium text-gray-700 mb-1">
|
|
148
|
+
Exported preview (on checkerboard)
|
|
149
|
+
</p>
|
|
150
|
+
<img
|
|
151
|
+
src={lightPreview}
|
|
152
|
+
alt="light mode signature preview on checkerboard"
|
|
153
|
+
className="border rounded max-h-24"
|
|
154
|
+
style={{
|
|
155
|
+
backgroundColor: "#ffffff",
|
|
156
|
+
backgroundImage:
|
|
157
|
+
"linear-gradient(45deg, #d1d5db 25%, transparent 25%, transparent 75%, #d1d5db 75%), linear-gradient(45deg, #d1d5db 25%, transparent 25%, transparent 75%, #d1d5db 75%)",
|
|
158
|
+
backgroundSize: "16px 16px",
|
|
159
|
+
backgroundPosition: "0 0, 8px 8px",
|
|
160
|
+
}}
|
|
161
|
+
/>
|
|
162
|
+
</div>
|
|
163
|
+
</div>
|
|
164
|
+
)}
|
|
165
|
+
</div>
|
|
166
|
+
</StorySection>
|
|
167
|
+
|
|
106
168
|
<StorySection
|
|
107
169
|
title="Dark mode"
|
|
108
|
-
description="darkMode=true switches label colours to
|
|
170
|
+
description="darkMode=true switches label colours and gives the pad a dark surface. With no penColor set, the pen defaults to white so the stroke is visible. The exported image is black ink on transparent (the white on-screen pen is recoloured on export) — the preview below sits on white to prove the saved signature reads correctly off a dark pad."
|
|
109
171
|
dark
|
|
110
172
|
>
|
|
111
173
|
<div className="w-full max-w-md">
|
|
112
174
|
<div className="h-[280px]">
|
|
113
175
|
<SignatureImpl
|
|
114
176
|
id="signature-dark"
|
|
177
|
+
ref={darkSignatureRef}
|
|
115
178
|
signing={false}
|
|
116
179
|
label="Signature"
|
|
117
180
|
optional
|
|
118
181
|
darkMode={true}
|
|
119
|
-
penColor="white"
|
|
120
182
|
signatureClear={true}
|
|
121
183
|
onChange={() => {}}
|
|
122
|
-
onSubmit={() =>
|
|
184
|
+
onSubmit={(_, dataURL) => setDarkPreview(dataURL ?? "")}
|
|
123
185
|
/>
|
|
124
186
|
</div>
|
|
187
|
+
<Button
|
|
188
|
+
className="mt-3"
|
|
189
|
+
size="small"
|
|
190
|
+
text="Submit"
|
|
191
|
+
type="normal"
|
|
192
|
+
onClick={() => darkSignatureRef.current?.handleSubmit()}
|
|
193
|
+
/>
|
|
194
|
+
{darkPreview && (
|
|
195
|
+
<div className="mt-3 rounded bg-white p-2">
|
|
196
|
+
<p className="text-sm font-medium text-gray-700 mb-1">
|
|
197
|
+
Exported preview (on white)
|
|
198
|
+
</p>
|
|
199
|
+
<img
|
|
200
|
+
src={darkPreview}
|
|
201
|
+
alt="dark mode signature preview"
|
|
202
|
+
className="border rounded max-h-24"
|
|
203
|
+
/>
|
|
204
|
+
</div>
|
|
205
|
+
)}
|
|
125
206
|
</div>
|
|
126
207
|
</StorySection>
|
|
127
208
|
|
|
@@ -6,7 +6,6 @@ import { StorySection } from "../../storyUtils";
|
|
|
6
6
|
const meta = { title: "Input/TextInput" };
|
|
7
7
|
export default meta;
|
|
8
8
|
|
|
9
|
-
|
|
10
9
|
export const TextInput = () => {
|
|
11
10
|
const refEmail = useRef<any>(null);
|
|
12
11
|
const refPassword = useRef<any>(null);
|
|
@@ -15,7 +14,6 @@ export const TextInput = () => {
|
|
|
15
14
|
const refCap = useRef<any>(null);
|
|
16
15
|
const refSuggest = useRef<any>(null);
|
|
17
16
|
const refRegex = useRef<any>(null);
|
|
18
|
-
|
|
19
17
|
const [email, setEmail] = useState("");
|
|
20
18
|
const [password, setPassword] = useState("");
|
|
21
19
|
const [xsValue, setXsValue] = useState("");
|
|
@@ -24,6 +22,7 @@ export const TextInput = () => {
|
|
|
24
22
|
const [capText, setCapText] = useState("");
|
|
25
23
|
const [suggestText, setSuggestText] = useState("");
|
|
26
24
|
const [regexText, setRegexText] = useState("");
|
|
25
|
+
const [barcodeText, setBarcodeText] = useState("");
|
|
27
26
|
|
|
28
27
|
return (
|
|
29
28
|
<>
|
|
@@ -51,7 +50,6 @@ export const TextInput = () => {
|
|
|
51
50
|
/>
|
|
52
51
|
</div>
|
|
53
52
|
</StorySection>
|
|
54
|
-
|
|
55
53
|
<StorySection
|
|
56
54
|
title="Extra small (xs)"
|
|
57
55
|
description="size='xs' looks 12px everywhere. On a phone it keeps 16px text and draws the field at 75%, so iOS Safari does not zoom when it is focused; from sm up it is a plain 12px field. enterKeyHint='done' labels the phone keyboard's enter key, and onKeyDown handles Enter here."
|
|
@@ -100,7 +98,6 @@ export const TextInput = () => {
|
|
|
100
98
|
/>
|
|
101
99
|
</div>
|
|
102
100
|
</StorySection>
|
|
103
|
-
|
|
104
101
|
<StorySection
|
|
105
102
|
title="Number (with min/max)"
|
|
106
103
|
description="type='number' prevents scroll-to-change. minNumber and maxNumber enforce range on submit."
|
|
@@ -126,7 +123,6 @@ export const TextInput = () => {
|
|
|
126
123
|
/>
|
|
127
124
|
</div>
|
|
128
125
|
</StorySection>
|
|
129
|
-
|
|
130
126
|
<StorySection
|
|
131
127
|
title="Mobile (with country picker)"
|
|
132
128
|
description="type='mobile' with mobilePicker shows a country flag/code selector. mobileCountryEditable allows changing the country."
|
|
@@ -151,7 +147,6 @@ export const TextInput = () => {
|
|
|
151
147
|
/>
|
|
152
148
|
</div>
|
|
153
149
|
</StorySection>
|
|
154
|
-
|
|
155
150
|
<StorySection
|
|
156
151
|
title="Capitalize Text"
|
|
157
152
|
description="capitalize=true forces all input to uppercase as the user types."
|
|
@@ -170,7 +165,6 @@ export const TextInput = () => {
|
|
|
170
165
|
/>
|
|
171
166
|
</div>
|
|
172
167
|
</StorySection>
|
|
173
|
-
|
|
174
168
|
<StorySection
|
|
175
169
|
title="With Suggestions"
|
|
176
170
|
description="strSuggestions provides a dropdown of matching options as the user types."
|
|
@@ -195,7 +189,6 @@ export const TextInput = () => {
|
|
|
195
189
|
/>
|
|
196
190
|
</div>
|
|
197
191
|
</StorySection>
|
|
198
|
-
|
|
199
192
|
<StorySection
|
|
200
193
|
title="Custom Regex Pattern + Form Validation"
|
|
201
194
|
description="pattern accepts a regex string. customErrMsg overrides the default error message. Call handleSubmit() via ref to trigger validation."
|
|
@@ -221,7 +214,33 @@ export const TextInput = () => {
|
|
|
221
214
|
/>
|
|
222
215
|
</div>
|
|
223
216
|
</StorySection>
|
|
224
|
-
|
|
217
|
+
<StorySection
|
|
218
|
+
title="Barcode Scan"
|
|
219
|
+
description="barcodeEnabled adds a viewfinder scan button to the top-right that opens the camera and prefills the value from a scanned barcode/QR. Uses the browser BarcodeDetector where available (Chrome / Android) and a ZXing-wasm polyfill elsewhere (e.g. iOS Safari). The camera needs https (or localhost)."
|
|
220
|
+
>
|
|
221
|
+
<div className="w-full max-w-sm space-y-6">
|
|
222
|
+
<TextInputImpl
|
|
223
|
+
id="assetTag"
|
|
224
|
+
value={barcodeText}
|
|
225
|
+
type="text"
|
|
226
|
+
label="Asset Tag"
|
|
227
|
+
placeholder="Enter or scan asset tag"
|
|
228
|
+
subLabel="Scan the barcode on the asset, or type it in"
|
|
229
|
+
barcodeEnabled
|
|
230
|
+
onChange={(e) => setBarcodeText(e.target.value)}
|
|
231
|
+
/>
|
|
232
|
+
<TextInputImpl
|
|
233
|
+
id="assetTagMobile"
|
|
234
|
+
value={barcodeText}
|
|
235
|
+
type="text"
|
|
236
|
+
label="Asset Tag (mobile layout)"
|
|
237
|
+
placeholder="Enter or scan asset tag"
|
|
238
|
+
barcodeEnabled
|
|
239
|
+
mobileView
|
|
240
|
+
onChange={(e) => setBarcodeText(e.target.value)}
|
|
241
|
+
/>
|
|
242
|
+
</div>
|
|
243
|
+
</StorySection>
|
|
225
244
|
<StorySection
|
|
226
245
|
title="Small & Mini sizes"
|
|
227
246
|
description="size='small' reduces padding to px-3 py-2. size='mini' reduces further to p-2."
|
|
@@ -249,4 +268,4 @@ export const TextInput = () => {
|
|
|
249
268
|
</StorySection>
|
|
250
269
|
</>
|
|
251
270
|
);
|
|
252
|
-
}
|
|
271
|
+
};
|
|
@@ -116,6 +116,8 @@ const navigation = [
|
|
|
116
116
|
| `draggable` | `boolean` | `false` | Enables drag-to-reorder on nav items. Requires `handleChangeNavigation`. |
|
|
117
117
|
| `handleChangeNavigation` | `(value: Navigation[]) => void` | — | Called with the updated nav array after each drag move. Required when `draggable` is `true`. |
|
|
118
118
|
| `handleDropEnd` | `() => void` | — | Called when a drag interaction ends. Required when `draggable` is `true`. |
|
|
119
|
+
| `handleMoveItem` | `(move: BarMove) => void` | — | Turns on moving rows in a tree (rows with `parentId`) by dragging — see Moving rows in a tree below. |
|
|
120
|
+
| `canDropItem` | `(dragId, targetId, position) => boolean` | — | Refuses a drop target before the drop, so a row that cannot hold children never lights up as one. |
|
|
119
121
|
| `handleBarsOnClick` | `(id: string) => void` | — | Called with the nav item `id` when clicked. Use instead of href navigation for single-page apps. |
|
|
120
122
|
| `handleToggleCollapse` | `() => void` | — | Called when the collapse arrow is clicked. Required when `transition` is set. |
|
|
121
123
|
| `transition` | `{ headerVisible?: boolean; collapse?: boolean }` | — | Controls sidebar collapse and header visibility — see Transition below. |
|
|
@@ -132,6 +134,43 @@ const navigation = [
|
|
|
132
134
|
| `persistKey` | `string` | — | localStorage key that remembers open groups and pages. A new key loads that key's stored state. |
|
|
133
135
|
| `defaultExpandedIds` | `string[]` | — | Groups (by `groupId`) and pages (by `id`) open when nothing is stored for them |
|
|
134
136
|
|
|
137
|
+
##### Moving rows in a tree
|
|
138
|
+
|
|
139
|
+
With `handleMoveItem` set and rows nested through `parentId`, each row can be dragged:
|
|
140
|
+
|
|
141
|
+
- **middle of a row** — file the dragged row **inside** it, as its last child
|
|
142
|
+
- **top / bottom edge** — place it **before / after** that row, under the same parent (how a row moves between parents)
|
|
143
|
+
- holding a row over a closed parent opens it after a moment
|
|
144
|
+
- on touch, **hold a row** to pick it up, then drag; a quick swipe still scrolls
|
|
145
|
+
- a list with nothing nested yet still works — passing `handleMoveItem` lays the rows out as a tree, so the first row can be dropped inside another
|
|
146
|
+
- a row can never be dropped inside itself or one of its own children
|
|
147
|
+
|
|
148
|
+
LeftNav never rearranges the tree itself. It reports one `BarMove` and the caller saves it and passes the new `navigation` back:
|
|
149
|
+
|
|
150
|
+
```tsx
|
|
151
|
+
import { BarMove, LeftNav } from "blixify-ui-web";
|
|
152
|
+
|
|
153
|
+
<LeftNav
|
|
154
|
+
navigation={rows}
|
|
155
|
+
handleMoveItem={(move: BarMove) => handleSaveMove(move)}
|
|
156
|
+
// INFO: e.g. only a page may hold others, and workspaces stay put
|
|
157
|
+
canDropItem={(dragId, targetId, position) =>
|
|
158
|
+
position !== "inside" || isPage(targetId)
|
|
159
|
+
}
|
|
160
|
+
{...otherProps}
|
|
161
|
+
/>
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
| `BarMove` field | Meaning |
|
|
165
|
+
| --------------- | -------------------------------------------------------------------- |
|
|
166
|
+
| `id` | the dragged row |
|
|
167
|
+
| `parentId` | the row it now sits under, or `null` for the top level |
|
|
168
|
+
| `beforeId` | the sibling it now sits in front of, or `null` for the end |
|
|
169
|
+
| `targetId` | the row it was dropped on |
|
|
170
|
+
| `position` | `"before"`, `"inside"` or `"after"` |
|
|
171
|
+
|
|
172
|
+
Moving stays off while the list is filtered, collapsed or horizontal. Without `handleMoveItem`, lists keep `draggable` for reordering. A caller that moves rows nests groups as rows, since the tree layout draws no `groupId` headers.
|
|
173
|
+
|
|
135
174
|
##### Navigation
|
|
136
175
|
|
|
137
176
|
| Name | Type | Description |
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { FolderIcon, HomeIcon, UsersIcon } from "@heroicons/react/24/outline";
|
|
2
2
|
import React, { useState } from "react";
|
|
3
|
-
import { LeftNav as LeftNavImpl } from ".";
|
|
3
|
+
import { LeftNav as LeftNavImpl, Navigation } from ".";
|
|
4
|
+
import { BarMove } from "../bars";
|
|
4
5
|
import { StorySection } from "../../storyUtils";
|
|
5
6
|
import Logo from "../../../assets/logo.png";
|
|
6
7
|
|
|
7
8
|
const meta = { title: "Navigation/LeftNav" };
|
|
8
9
|
export default meta;
|
|
9
10
|
|
|
10
|
-
|
|
11
11
|
const baseNavigation = [
|
|
12
12
|
{ id: "dashboard", name: "Dashboard", href: "/dashboard", icon: HomeIcon },
|
|
13
13
|
{
|
|
@@ -55,26 +55,109 @@ const groupedNavigation = [
|
|
|
55
55
|
},
|
|
56
56
|
];
|
|
57
57
|
|
|
58
|
-
|
|
59
58
|
const manyNavigation = [
|
|
60
|
-
{
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
{
|
|
68
|
-
|
|
69
|
-
|
|
59
|
+
{
|
|
60
|
+
id: "home",
|
|
61
|
+
name: "Home",
|
|
62
|
+
href: "/home",
|
|
63
|
+
icon: HomeIcon,
|
|
64
|
+
description: "Dashboard overview and recent activity",
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
id: "journals",
|
|
68
|
+
name: "Journals",
|
|
69
|
+
href: "/journals",
|
|
70
|
+
icon: FolderIcon,
|
|
71
|
+
description: "Peer reviewed medical journal volumes",
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
id: "vol2",
|
|
75
|
+
name: "Journal Volume 2",
|
|
76
|
+
href: "/j/2",
|
|
77
|
+
icon: FolderIcon,
|
|
78
|
+
parentId: "journals",
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
id: "vol3",
|
|
82
|
+
name: "Journal Volume 3",
|
|
83
|
+
href: "/j/3",
|
|
84
|
+
icon: FolderIcon,
|
|
85
|
+
parentId: "journals",
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
id: "charts",
|
|
89
|
+
name: "Charts",
|
|
90
|
+
href: "/charts",
|
|
91
|
+
icon: FolderIcon,
|
|
92
|
+
description: "Analytics and research dashboards",
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
id: "archive",
|
|
96
|
+
name: "Archive",
|
|
97
|
+
href: "/archive",
|
|
98
|
+
icon: FolderIcon,
|
|
99
|
+
description: "Institutional records and strategic memory",
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
id: "governance",
|
|
103
|
+
name: "Governance",
|
|
104
|
+
href: "/gov",
|
|
105
|
+
icon: FolderIcon,
|
|
106
|
+
description: "Policies, SOPs and quality documentation",
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
id: "theses",
|
|
110
|
+
name: "Theses",
|
|
111
|
+
href: "/theses",
|
|
112
|
+
icon: FolderIcon,
|
|
113
|
+
description: "Postgraduate and capstone scholarship",
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
id: "policies",
|
|
117
|
+
name: "Policies",
|
|
118
|
+
href: "/policies",
|
|
119
|
+
icon: FolderIcon,
|
|
120
|
+
description: "Reimbursement claims and expense rules",
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
id: "settings",
|
|
124
|
+
name: "Settings",
|
|
125
|
+
href: "/settings",
|
|
126
|
+
icon: UsersIcon,
|
|
127
|
+
description: "Workspace preferences and member access",
|
|
128
|
+
},
|
|
70
129
|
];
|
|
71
130
|
|
|
72
131
|
const twoGroupNavigation = [
|
|
73
132
|
{ id: "home", name: "Home", href: "/home", icon: HomeIcon },
|
|
74
|
-
{
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
133
|
+
{
|
|
134
|
+
id: "eng",
|
|
135
|
+
name: "Engineering",
|
|
136
|
+
href: "/t/eng",
|
|
137
|
+
icon: UsersIcon,
|
|
138
|
+
groupId: "People",
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
id: "hr",
|
|
142
|
+
name: "Human Resources",
|
|
143
|
+
href: "/t/hr",
|
|
144
|
+
icon: UsersIcon,
|
|
145
|
+
groupId: "People",
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
id: "vol2",
|
|
149
|
+
name: "Volume 2",
|
|
150
|
+
href: "/j/2",
|
|
151
|
+
icon: FolderIcon,
|
|
152
|
+
groupId: "Journals",
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
id: "vol3",
|
|
156
|
+
name: "Volume 3",
|
|
157
|
+
href: "/j/3",
|
|
158
|
+
icon: FolderIcon,
|
|
159
|
+
groupId: "Journals",
|
|
160
|
+
},
|
|
78
161
|
];
|
|
79
162
|
|
|
80
163
|
// INFO: the shape Space builds — workspace rows, group rows under them, then
|
|
@@ -82,14 +165,55 @@ const twoGroupNavigation = [
|
|
|
82
165
|
// across workspaces on purpose.
|
|
83
166
|
const workspaceNavigation = [
|
|
84
167
|
{ id: "ws-sales", name: "Sales", href: "/ws/sales", icon: UsersIcon },
|
|
85
|
-
{
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
168
|
+
{
|
|
169
|
+
id: "sales-reports",
|
|
170
|
+
name: "Reports",
|
|
171
|
+
href: "/g/sales-reports",
|
|
172
|
+
icon: FolderIcon,
|
|
173
|
+
parentId: "ws-sales",
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
id: "sales-charts",
|
|
177
|
+
name: "Charts",
|
|
178
|
+
href: "/s/sales-charts",
|
|
179
|
+
icon: FolderIcon,
|
|
180
|
+
parentId: "sales-reports",
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
id: "sales-q3",
|
|
184
|
+
name: "Q3 Review",
|
|
185
|
+
href: "/s/sales-q3",
|
|
186
|
+
icon: FolderIcon,
|
|
187
|
+
parentId: "sales-charts",
|
|
188
|
+
},
|
|
189
|
+
{
|
|
190
|
+
id: "ws-marketing",
|
|
191
|
+
name: "Marketing",
|
|
192
|
+
href: "/ws/marketing",
|
|
193
|
+
icon: UsersIcon,
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
id: "marketing-reports",
|
|
197
|
+
name: "Reports",
|
|
198
|
+
href: "/g/marketing-reports",
|
|
199
|
+
icon: FolderIcon,
|
|
200
|
+
parentId: "ws-marketing",
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
id: "marketing-charts",
|
|
204
|
+
name: "Charts",
|
|
205
|
+
href: "/s/marketing-charts",
|
|
206
|
+
icon: FolderIcon,
|
|
207
|
+
parentId: "marketing-reports",
|
|
208
|
+
},
|
|
91
209
|
{ id: "ws-ops", name: "Operations", href: "/ws/ops", icon: UsersIcon },
|
|
92
|
-
{
|
|
210
|
+
{
|
|
211
|
+
id: "ops-charts",
|
|
212
|
+
name: "Charts",
|
|
213
|
+
href: "/s/ops-charts",
|
|
214
|
+
icon: FolderIcon,
|
|
215
|
+
parentId: "ws-ops",
|
|
216
|
+
},
|
|
93
217
|
];
|
|
94
218
|
|
|
95
219
|
const renderLogo = (collapse?: boolean) => (
|
|
@@ -112,6 +236,30 @@ const renderHeader = () => (
|
|
|
112
236
|
<div className="px-4 text-sm font-medium text-gray-700">Page Title</div>
|
|
113
237
|
);
|
|
114
238
|
|
|
239
|
+
/**
|
|
240
|
+
* INFO: what a caller does with a BarMove — the row takes its new parent and
|
|
241
|
+
* is put in front of `beforeId`, or after the last of its new siblings. The
|
|
242
|
+
* story keeps it in state; an app saves it and reloads its navigation.
|
|
243
|
+
*/
|
|
244
|
+
const handleApplyMove = (rows: Navigation[], move: BarMove): Navigation[] => {
|
|
245
|
+
const moving = rows.find((each) => each.id === move.id);
|
|
246
|
+
if (!moving) return rows;
|
|
247
|
+
const moved = { ...moving, parentId: move.parentId };
|
|
248
|
+
const rest = rows.filter((each) => each.id !== move.id);
|
|
249
|
+
let index = move.beforeId
|
|
250
|
+
? rest.findIndex((each) => each.id === move.beforeId)
|
|
251
|
+
: -1;
|
|
252
|
+
if (index < 0) {
|
|
253
|
+
const siblings = rest.filter(
|
|
254
|
+
(each) => (each.parentId ?? null) === move.parentId,
|
|
255
|
+
);
|
|
256
|
+
const last = siblings[siblings.length - 1];
|
|
257
|
+
index = last ? rest.indexOf(last) + 1 : rest.length;
|
|
258
|
+
}
|
|
259
|
+
rest.splice(index, 0, moved);
|
|
260
|
+
return rest;
|
|
261
|
+
};
|
|
262
|
+
|
|
115
263
|
export const LeftNav = () => {
|
|
116
264
|
const [mobile, setMobile] = useState(false);
|
|
117
265
|
const [selectedId, setSelectedId] = useState("dashboard");
|
|
@@ -125,6 +273,8 @@ export const LeftNav = () => {
|
|
|
125
273
|
const [groupedSelectedId, setGroupedSelectedId] = useState("engineering");
|
|
126
274
|
// INFO: starts three levels down, so the story shows its branch open on mount
|
|
127
275
|
const [workspaceSelectedId, setWorkspaceSelectedId] = useState("sales-q3");
|
|
276
|
+
const [movableNav, setMovableNav] = useState(workspaceNavigation);
|
|
277
|
+
const [lastMove, setLastMove] = useState("");
|
|
128
278
|
|
|
129
279
|
return (
|
|
130
280
|
<>
|
|
@@ -175,12 +325,47 @@ export const LeftNav = () => {
|
|
|
175
325
|
<LeftNavImpl
|
|
176
326
|
selectedId={groupedSelectedId}
|
|
177
327
|
navigation={[
|
|
178
|
-
{
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
328
|
+
{
|
|
329
|
+
id: "dashboard",
|
|
330
|
+
name: "Dashboard",
|
|
331
|
+
href: "/dashboard",
|
|
332
|
+
icon: HomeIcon,
|
|
333
|
+
},
|
|
334
|
+
{
|
|
335
|
+
id: "teams",
|
|
336
|
+
name: "Teams",
|
|
337
|
+
href: "/teams",
|
|
338
|
+
icon: UsersIcon,
|
|
339
|
+
groupId: "People",
|
|
340
|
+
},
|
|
341
|
+
{
|
|
342
|
+
id: "engineering",
|
|
343
|
+
name: "Engineering",
|
|
344
|
+
href: "/teams/engineering",
|
|
345
|
+
icon: UsersIcon,
|
|
346
|
+
groupId: "People",
|
|
347
|
+
},
|
|
348
|
+
{
|
|
349
|
+
id: "hr",
|
|
350
|
+
name: "Human Resources",
|
|
351
|
+
href: "/teams/hr",
|
|
352
|
+
icon: UsersIcon,
|
|
353
|
+
groupId: "People",
|
|
354
|
+
},
|
|
355
|
+
{
|
|
356
|
+
id: "reports",
|
|
357
|
+
name: "Reports",
|
|
358
|
+
href: "/reports",
|
|
359
|
+
icon: FolderIcon,
|
|
360
|
+
groupId: "Site data",
|
|
361
|
+
},
|
|
362
|
+
{
|
|
363
|
+
id: "exports",
|
|
364
|
+
name: "Exports",
|
|
365
|
+
href: "/exports",
|
|
366
|
+
icon: FolderIcon,
|
|
367
|
+
groupId: "Site data",
|
|
368
|
+
},
|
|
184
369
|
]}
|
|
185
370
|
leftNavMobileMenu={false}
|
|
186
371
|
handleBarsOnClick={(id) => setGroupedSelectedId(id)}
|
|
@@ -336,6 +521,42 @@ export const LeftNav = () => {
|
|
|
336
521
|
</div>
|
|
337
522
|
</StorySection>
|
|
338
523
|
|
|
524
|
+
<StorySection
|
|
525
|
+
title="Move In The Tree"
|
|
526
|
+
description="Drag a page by its row. Drop on the middle of a page to file it inside, or on its top or bottom edge to place it before or after that page, under the same parent — that is how a page moves between parents. Workspaces only take a page inside them, and cannot be dragged themselves (canDropItem). Hold a page over a closed row to open it. On a phone, hold a page to pick it up, then drag. A page can never go inside itself or its own sub pages. LeftNav only reports each move; the story applies it to its own state, the way an app saves it and reloads."
|
|
527
|
+
>
|
|
528
|
+
<div className="w-full h-96 relative overflow-hidden border rounded-lg">
|
|
529
|
+
<LeftNavImpl
|
|
530
|
+
selectedId={workspaceSelectedId}
|
|
531
|
+
navigation={movableNav}
|
|
532
|
+
leftNavMobileMenu={false}
|
|
533
|
+
compactMode={true}
|
|
534
|
+
defaultExpandedIds={["ws-sales", "ws-marketing"]}
|
|
535
|
+
handleBarsOnClick={(id) => setWorkspaceSelectedId(id)}
|
|
536
|
+
handleMoveItem={(move) => {
|
|
537
|
+
setMovableNav((rows) => handleApplyMove(rows, move));
|
|
538
|
+
setLastMove(
|
|
539
|
+
`${move.id} → ${move.position} ${move.targetId} (parent ${move.parentId ?? "top level"})`,
|
|
540
|
+
);
|
|
541
|
+
}}
|
|
542
|
+
canDropItem={(dragId, targetId, position) =>
|
|
543
|
+
!dragId.startsWith("ws-") &&
|
|
544
|
+
(!targetId.startsWith("ws-") || position === "inside")
|
|
545
|
+
}
|
|
546
|
+
handleToggleLeftNavMobileMenu={() => {}}
|
|
547
|
+
renderDashboardLogo={() => renderLogo()}
|
|
548
|
+
renderDashboardContent={() => (
|
|
549
|
+
<div className="p-6 text-sm text-gray-500">
|
|
550
|
+
{lastMove
|
|
551
|
+
? `Last move: ${lastMove}`
|
|
552
|
+
: "Drag a page in the sidebar."}
|
|
553
|
+
</div>
|
|
554
|
+
)}
|
|
555
|
+
renderDashboardHeaderContent={renderHeader}
|
|
556
|
+
/>
|
|
557
|
+
</div>
|
|
558
|
+
</StorySection>
|
|
559
|
+
|
|
339
560
|
<StorySection
|
|
340
561
|
title="Persistent Expand State"
|
|
341
562
|
description="persistKey names a localStorage entry that remembers which groups and pages are open. Expand a group, reload the page, and it is still open — without it expansion lasts one mount only, so anything opened is shut again by the next navigation. Groups are keyed by groupId and pages by id. Omit persistKey and no storage is touched at all."
|
|
@@ -403,4 +624,4 @@ export const LeftNav = () => {
|
|
|
403
624
|
</StorySection>
|
|
404
625
|
</>
|
|
405
626
|
);
|
|
406
|
-
}
|
|
627
|
+
};
|