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.
@@ -80,7 +80,7 @@ return (
80
80
 
81
81
  | Name | Type | Default | Description |
82
82
  | ---------------- | ----------------------------- | -------------------------------------- | ----------------------------------------------------------------------------------------------- |
83
- | `positionOffset` | `string` | `"bottom-28 right-4 md:bottom-6 md:right-6"` | Tailwind classes that control the fixed position of the anchor. On phones the default starts above a bottom bar such as DataTemplate's Quick find. Ignored on mobile when open. |
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";
@@ -224,7 +224,7 @@ export const DataWrapper = () => {
224
224
  }}
225
225
  bareSettings={{
226
226
  bareCompactMode: true,
227
- bareNewDTW: listLayout === "calendar",
227
+ bareNewDTW: true,
228
228
  bareOffline: false,
229
229
  bareListComponent:
230
230
  listLayout === "customList"
@@ -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
- | Prop | Type | Notes |
8
- | -------------------------------------- | --------------------------------------------- | ----------------------------------------------------------------------- |
9
- | `paginationType` | `"page" \| "cursor" \| "scroll"` | `scroll` shows only the result count; nothing renders when unset |
10
- | `pageIndex`, `limit`, `size`, `cursor` | | same meaning as on NewTable |
11
- | `resultCount` | `number` | rows on the current page — the count shown for cursor / scroll paging |
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=&quot;sm&quot; 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 suit a dark background."
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
- { id: "home", name: "Home", href: "/home", icon: HomeIcon, description: "Dashboard overview and recent activity" },
61
- { id: "journals", name: "Journals", href: "/journals", icon: FolderIcon, description: "Peer reviewed medical journal volumes" },
62
- { id: "vol2", name: "Journal Volume 2", href: "/j/2", icon: FolderIcon, parentId: "journals" },
63
- { id: "vol3", name: "Journal Volume 3", href: "/j/3", icon: FolderIcon, parentId: "journals" },
64
- { id: "charts", name: "Charts", href: "/charts", icon: FolderIcon, description: "Analytics and research dashboards" },
65
- { id: "archive", name: "Archive", href: "/archive", icon: FolderIcon, description: "Institutional records and strategic memory" },
66
- { id: "governance", name: "Governance", href: "/gov", icon: FolderIcon, description: "Policies, SOPs and quality documentation" },
67
- { id: "theses", name: "Theses", href: "/theses", icon: FolderIcon, description: "Postgraduate and capstone scholarship" },
68
- { id: "policies", name: "Policies", href: "/policies", icon: FolderIcon, description: "Reimbursement claims and expense rules" },
69
- { id: "settings", name: "Settings", href: "/settings", icon: UsersIcon, description: "Workspace preferences and member access" },
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
- { id: "eng", name: "Engineering", href: "/t/eng", icon: UsersIcon, groupId: "People" },
75
- { id: "hr", name: "Human Resources", href: "/t/hr", icon: UsersIcon, groupId: "People" },
76
- { id: "vol2", name: "Volume 2", href: "/j/2", icon: FolderIcon, groupId: "Journals" },
77
- { id: "vol3", name: "Volume 3", href: "/j/3", icon: FolderIcon, groupId: "Journals" },
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
- { id: "sales-reports", name: "Reports", href: "/g/sales-reports", icon: FolderIcon, parentId: "ws-sales" },
86
- { id: "sales-charts", name: "Charts", href: "/s/sales-charts", icon: FolderIcon, parentId: "sales-reports" },
87
- { id: "sales-q3", name: "Q3 Review", href: "/s/sales-q3", icon: FolderIcon, parentId: "sales-charts" },
88
- { id: "ws-marketing", name: "Marketing", href: "/ws/marketing", icon: UsersIcon },
89
- { id: "marketing-reports", name: "Reports", href: "/g/marketing-reports", icon: FolderIcon, parentId: "ws-marketing" },
90
- { id: "marketing-charts", name: "Charts", href: "/s/marketing-charts", icon: FolderIcon, parentId: "marketing-reports" },
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
- { id: "ops-charts", name: "Charts", href: "/s/ops-charts", icon: FolderIcon, parentId: "ws-ops" },
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
- { id: "dashboard", name: "Dashboard", href: "/dashboard", icon: HomeIcon },
179
- { id: "teams", name: "Teams", href: "/teams", icon: UsersIcon, groupId: "People" },
180
- { id: "engineering", name: "Engineering", href: "/teams/engineering", icon: UsersIcon, groupId: "People" },
181
- { id: "hr", name: "Human Resources", href: "/teams/hr", icon: UsersIcon, groupId: "People" },
182
- { id: "reports", name: "Reports", href: "/reports", icon: FolderIcon, groupId: "Site data" },
183
- { id: "exports", name: "Exports", href: "/exports", icon: FolderIcon, groupId: "Site data" },
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
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "blixify-ui-web-mcp",
3
- "version": "0.1.41",
3
+ "version": "0.1.42",
4
4
  "license": "MIT",
5
5
  "main": "lib/mcp/server.js",
6
6
  "bin": {