@byline/host-tanstack-start 2.2.6 → 2.2.8

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.
@@ -20,12 +20,15 @@
20
20
  }
21
21
 
22
22
  :is(.left-wd7pIB, .byline-admin-app-bar-left) {
23
+ flex: auto;
23
24
  align-items: center;
24
25
  gap: 1rem;
26
+ min-width: 0;
25
27
  display: flex;
26
28
  }
27
29
 
28
30
  :is(.right-w1uns3, .byline-admin-app-bar-right) {
31
+ flex: none;
29
32
  align-items: center;
30
33
  gap: 1rem;
31
34
  font-size: .875rem;
@@ -1,10 +1,3 @@
1
- /**
2
- * This Source Code is subject to the terms of the Mozilla Public
3
- * License, v. 2.0. If a copy of the MPL was not distributed with this
4
- * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
- *
6
- * Copyright (c) Infonomic Company Limited
7
- */
8
1
  import type { Breadcrumb } from './@types.js';
9
2
  export declare function Breadcrumbs({ breadcrumbs, className, homeLabel, homePath, }: {
10
3
  breadcrumbs: Breadcrumb[];
@@ -1,7 +1,12 @@
1
+ "use client";
1
2
  import { jsx, jsxs } from "react/jsx-runtime";
3
+ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
4
+ import { Dropdown, EllipsisIcon } from "@byline/ui/react";
2
5
  import classnames from "classnames";
3
6
  import { Link } from "../loose-router.js";
4
7
  import breadcrumbs_module from "./breadcrumbs.module.js";
8
+ const useIsoLayoutEffect = "u" > typeof window ? useLayoutEffect : useEffect;
9
+ const MAX_LABEL_LENGTH = 20;
5
10
  function truncate(str, length, useWordBoundary = true, useSuffix = true) {
6
11
  if (null == str || str.length <= length) return str;
7
12
  const subString = str.slice(0, length - 2);
@@ -9,69 +14,212 @@ function truncate(str, length, useWordBoundary = true, useSuffix = true) {
9
14
  return useSuffix ? `${truncated}...` : truncated;
10
15
  }
11
16
  function Breadcrumbs({ breadcrumbs, className, homeLabel = 'Home', homePath = '/' }) {
12
- return /*#__PURE__*/ jsx("nav", {
17
+ const navRef = useRef(null);
18
+ const measureRef = useRef(null);
19
+ const [visibleIndices, setVisibleIndices] = useState(()=>breadcrumbs.map((_, i)=>i));
20
+ useIsoLayoutEffect(()=>{
21
+ setVisibleIndices(breadcrumbs.map((_, i)=>i));
22
+ }, [
23
+ breadcrumbs
24
+ ]);
25
+ useIsoLayoutEffect(()=>{
26
+ const nav = navRef.current;
27
+ const measure = measureRef.current;
28
+ if (!nav || !measure) return;
29
+ const compute = ()=>{
30
+ const children = Array.from(measure.children);
31
+ if (children.length !== breadcrumbs.length + 2) return;
32
+ const containerWidth = nav.clientWidth;
33
+ const homeWidth = children[0].offsetWidth;
34
+ const triggerWidth = children[children.length - 1].offsetWidth;
35
+ const itemWidths = children.slice(1, -1).map((el)=>el.offsetWidth);
36
+ const gap = Number.parseFloat(getComputedStyle(measure).gap) || 4;
37
+ const n = itemWidths.length;
38
+ let next;
39
+ const allTotal = homeWidth + itemWidths.reduce((a, b)=>a + b, 0) + n * gap;
40
+ if (n <= 2 || allTotal <= containerWidth) next = itemWidths.map((_, i)=>i);
41
+ else {
42
+ const visible = new Set([
43
+ 0,
44
+ n - 1
45
+ ]);
46
+ const baselineGaps = 4 * gap;
47
+ let used = homeWidth + itemWidths[0] + triggerWidth + itemWidths[n - 1] + baselineGaps;
48
+ if (used > containerWidth) {
49
+ visible.delete(0);
50
+ used = homeWidth + triggerWidth + itemWidths[n - 1] + 3 * gap;
51
+ }
52
+ for(let i = n - 2; i >= 1; i--){
53
+ if (visible.has(i)) continue;
54
+ const cost = itemWidths[i] + gap;
55
+ if (used + cost > containerWidth) break;
56
+ used += cost;
57
+ visible.add(i);
58
+ }
59
+ next = [
60
+ ...visible
61
+ ].sort((a, b)=>a - b);
62
+ }
63
+ setVisibleIndices((prev)=>{
64
+ if (prev.length === next.length && prev.every((v, i)=>v === next[i])) return prev;
65
+ return next;
66
+ });
67
+ };
68
+ compute();
69
+ const ro = new ResizeObserver(compute);
70
+ ro.observe(nav);
71
+ return ()=>ro.disconnect();
72
+ }, [
73
+ breadcrumbs
74
+ ]);
75
+ const overflowed = useMemo(()=>{
76
+ const visible = new Set(visibleIndices);
77
+ return breadcrumbs.filter((_, i)=>!visible.has(i));
78
+ }, [
79
+ breadcrumbs,
80
+ visibleIndices
81
+ ]);
82
+ const visibleSet = new Set(visibleIndices);
83
+ const lastIndex = breadcrumbs.length - 1;
84
+ let overflowEmitted = false;
85
+ const rendered = [];
86
+ for(let i = 0; i < breadcrumbs.length; i++)if (visibleSet.has(i)) rendered.push(renderBreadcrumb(breadcrumbs[i], i === lastIndex));
87
+ else if (!overflowEmitted) {
88
+ overflowEmitted = true;
89
+ rendered.push(/*#__PURE__*/ jsx(OverflowDropdown, {
90
+ items: overflowed
91
+ }, "__overflow__"));
92
+ }
93
+ return /*#__PURE__*/ jsxs("nav", {
94
+ ref: navRef,
13
95
  "aria-label": "Breadcrumb",
14
96
  className: classnames('byline-breadcrumbs', breadcrumbs_module.nav, className),
15
- children: /*#__PURE__*/ jsxs("ul", {
16
- className: classnames('byline-breadcrumbs-list', breadcrumbs_module.list),
17
- children: [
18
- /*#__PURE__*/ jsx("li", {
19
- className: classnames('byline-breadcrumbs-item', breadcrumbs_module.item),
20
- children: /*#__PURE__*/ jsxs(Link, {
21
- to: homePath,
22
- className: classnames('byline-breadcrumbs-link', breadcrumbs_module.link),
97
+ children: [
98
+ /*#__PURE__*/ jsxs("ul", {
99
+ className: classnames('byline-breadcrumbs-list', breadcrumbs_module.list),
100
+ children: [
101
+ /*#__PURE__*/ jsx(HomeItem, {
102
+ homePath: homePath,
103
+ homeLabel: homeLabel
104
+ }),
105
+ rendered
106
+ ]
107
+ }),
108
+ /*#__PURE__*/ jsxs("ul", {
109
+ ref: measureRef,
110
+ "aria-hidden": true,
111
+ className: classnames(breadcrumbs_module.list, breadcrumbs_module.measure),
112
+ children: [
113
+ /*#__PURE__*/ jsx(HomeItem, {
114
+ homePath: homePath,
115
+ homeLabel: homeLabel
116
+ }),
117
+ breadcrumbs.map((b, i)=>renderBreadcrumb(b, i === lastIndex)),
118
+ /*#__PURE__*/ jsxs("li", {
119
+ className: classnames('byline-breadcrumbs-item', breadcrumbs_module.item),
23
120
  children: [
24
- /*#__PURE__*/ jsx("svg", {
25
- role: "presentation",
26
- className: classnames('byline-breadcrumbs-home-icon', breadcrumbs_module.homeIcon),
27
- fill: "currentColor",
28
- viewBox: "0 0 20 20",
29
- xmlns: "http://www.w3.org/2000/svg",
30
- children: /*#__PURE__*/ jsx("path", {
31
- d: "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z"
121
+ /*#__PURE__*/ jsx(ChevronIcon, {}),
122
+ /*#__PURE__*/ jsx("span", {
123
+ className: classnames('byline-breadcrumbs-overflow-trigger', breadcrumbs_module.overflowTrigger),
124
+ children: /*#__PURE__*/ jsx(EllipsisIcon, {
125
+ className: classnames('byline-breadcrumbs-overflow-icon', breadcrumbs_module.overflowIcon)
32
126
  })
33
- }),
34
- homeLabel
127
+ })
35
128
  ]
36
129
  })
130
+ ]
131
+ })
132
+ ]
133
+ });
134
+ }
135
+ function renderBreadcrumb(breadcrumb, isLeaf) {
136
+ return /*#__PURE__*/ jsxs("li", {
137
+ "aria-current": isLeaf ? 'page' : void 0,
138
+ className: classnames('byline-breadcrumbs-item', breadcrumbs_module.item),
139
+ children: [
140
+ /*#__PURE__*/ jsx(ChevronIcon, {
141
+ isLeaf: isLeaf
142
+ }),
143
+ isLeaf ? /*#__PURE__*/ jsx("span", {
144
+ className: classnames('byline-breadcrumbs-leaf', breadcrumbs_module.leaf),
145
+ children: truncate(breadcrumb.label, MAX_LABEL_LENGTH, true)
146
+ }) : /*#__PURE__*/ jsx(Link, {
147
+ to: breadcrumb.href,
148
+ className: classnames('byline-breadcrumbs-link', breadcrumbs_module.link),
149
+ children: truncate(breadcrumb.label, MAX_LABEL_LENGTH, true)
150
+ })
151
+ ]
152
+ }, breadcrumb.href);
153
+ }
154
+ function HomeItem({ homePath, homeLabel }) {
155
+ return /*#__PURE__*/ jsx("li", {
156
+ className: classnames('byline-breadcrumbs-item', breadcrumbs_module.item),
157
+ children: /*#__PURE__*/ jsxs(Link, {
158
+ to: homePath,
159
+ className: classnames('byline-breadcrumbs-link', breadcrumbs_module.link),
160
+ children: [
161
+ /*#__PURE__*/ jsx("svg", {
162
+ role: "presentation",
163
+ className: classnames('byline-breadcrumbs-home-icon', breadcrumbs_module.homeIcon),
164
+ fill: "currentColor",
165
+ viewBox: "0 0 20 20",
166
+ xmlns: "http://www.w3.org/2000/svg",
167
+ children: /*#__PURE__*/ jsx("path", {
168
+ d: "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z"
169
+ })
37
170
  }),
38
- null != breadcrumbs && breadcrumbs.length > 0 && breadcrumbs.map((breadcrumb, index)=>{
39
- const isLeaf = index === breadcrumbs.length - 1;
40
- return /*#__PURE__*/ jsx("li", {
41
- "aria-current": isLeaf ? 'page' : void 0,
42
- className: classnames('byline-breadcrumbs-item', breadcrumbs_module.item),
43
- children: /*#__PURE__*/ jsxs("div", {
44
- className: classnames('byline-breadcrumbs-item-row', breadcrumbs_module.item),
45
- children: [
46
- /*#__PURE__*/ jsx("svg", {
47
- role: "presentation",
48
- className: classnames('byline-breadcrumbs-chevron', breadcrumbs_module.chevron, {
49
- 'byline-breadcrumbs-chevron-current': isLeaf,
50
- [breadcrumbs_module.chevronCurrent]: isLeaf
51
- }),
52
- fill: "currentColor",
53
- viewBox: "0 0 20 20",
54
- xmlns: "http://www.w3.org/2000/svg",
55
- children: /*#__PURE__*/ jsx("path", {
56
- fillRule: "evenodd",
57
- d: "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z",
58
- clipRule: "evenodd"
59
- })
60
- }),
61
- isLeaf ? /*#__PURE__*/ jsx("span", {
62
- className: classnames('byline-breadcrumbs-leaf', breadcrumbs_module.leaf),
63
- children: truncate(breadcrumb.label, 20, true)
64
- }) : /*#__PURE__*/ jsx(Link, {
65
- to: breadcrumb.href,
66
- className: classnames('byline-breadcrumbs-link', breadcrumbs_module.link),
67
- children: truncate(breadcrumb.label, 20, true)
68
- })
69
- ]
70
- })
71
- }, breadcrumb.href);
72
- })
171
+ homeLabel
73
172
  ]
74
173
  })
75
174
  });
76
175
  }
176
+ function ChevronIcon({ isLeaf = false }) {
177
+ return /*#__PURE__*/ jsx("svg", {
178
+ role: "presentation",
179
+ className: classnames('byline-breadcrumbs-chevron', breadcrumbs_module.chevron, {
180
+ 'byline-breadcrumbs-chevron-current': isLeaf,
181
+ [breadcrumbs_module.chevronCurrent]: isLeaf
182
+ }),
183
+ fill: "currentColor",
184
+ viewBox: "0 0 20 20",
185
+ xmlns: "http://www.w3.org/2000/svg",
186
+ children: /*#__PURE__*/ jsx("path", {
187
+ fillRule: "evenodd",
188
+ d: "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z",
189
+ clipRule: "evenodd"
190
+ })
191
+ });
192
+ }
193
+ function OverflowDropdown({ items }) {
194
+ return /*#__PURE__*/ jsxs("li", {
195
+ className: classnames('byline-breadcrumbs-item', breadcrumbs_module.item),
196
+ children: [
197
+ /*#__PURE__*/ jsx(ChevronIcon, {}),
198
+ /*#__PURE__*/ jsxs(Dropdown.Root, {
199
+ children: [
200
+ /*#__PURE__*/ jsx(Dropdown.Trigger, {
201
+ "aria-label": "Show hidden breadcrumbs",
202
+ className: classnames('byline-breadcrumbs-overflow-trigger', breadcrumbs_module.overflowTrigger),
203
+ children: /*#__PURE__*/ jsx(EllipsisIcon, {
204
+ className: classnames('byline-breadcrumbs-overflow-icon', breadcrumbs_module.overflowIcon)
205
+ })
206
+ }),
207
+ /*#__PURE__*/ jsx(Dropdown.Portal, {
208
+ children: /*#__PURE__*/ jsx(Dropdown.Content, {
209
+ sideOffset: 5,
210
+ align: "start",
211
+ children: items.map((item)=>/*#__PURE__*/ jsx(Dropdown.Item, {
212
+ className: classnames('byline-breadcrumbs-overflow-item', breadcrumbs_module.overflowItem),
213
+ render: /*#__PURE__*/ jsx(Link, {
214
+ to: item.href
215
+ }),
216
+ children: item.label
217
+ }, item.href))
218
+ })
219
+ })
220
+ ]
221
+ })
222
+ ]
223
+ });
224
+ }
77
225
  export { Breadcrumbs };
@@ -2,11 +2,15 @@ import "./breadcrumbs_module.css";
2
2
  const breadcrumbs_module = {
3
3
  nav: "nav-v78C9V",
4
4
  list: "list-YJoq_f",
5
+ measure: "measure-lRWlDd",
5
6
  item: "item-BqSVfE",
6
7
  link: "link-g18cLE",
7
8
  leaf: "leaf-X3Q13H",
8
9
  homeIcon: "homeIcon-yUKJfw",
9
10
  chevron: "chevron-VWUUs9",
10
- chevronCurrent: "chevronCurrent-vgprvC"
11
+ chevronCurrent: "chevronCurrent-vgprvC",
12
+ overflowTrigger: "overflowTrigger-Y8XJnz",
13
+ overflowIcon: "overflowIcon-FMJ1GK",
14
+ overflowItem: "overflowItem-zVrxKo"
11
15
  };
12
16
  export default breadcrumbs_module;
@@ -1,9 +1,14 @@
1
1
  :is(.nav-v78C9V, .byline-breadcrumbs) {
2
+ flex: auto;
3
+ min-width: 0;
2
4
  display: flex;
5
+ position: relative;
6
+ overflow: hidden;
3
7
  }
4
8
 
5
9
  :is(.list-YJoq_f, .byline-breadcrumbs-list) {
6
- flex-wrap: wrap;
10
+ white-space: nowrap;
11
+ flex-wrap: nowrap;
7
12
  align-items: center;
8
13
  gap: .25rem;
9
14
  margin: 0;
@@ -12,7 +17,16 @@
12
17
  display: inline-flex;
13
18
  }
14
19
 
20
+ .measure-lRWlDd {
21
+ visibility: hidden;
22
+ pointer-events: none;
23
+ position: absolute;
24
+ top: 0;
25
+ left: 0;
26
+ }
27
+
15
28
  :is(.item-BqSVfE, .byline-breadcrumbs-item) {
29
+ flex: none;
16
30
  align-items: center;
17
31
  margin: 0;
18
32
  padding: 0;
@@ -79,3 +93,85 @@
79
93
  color: var(--gray-600);
80
94
  }
81
95
 
96
+ :is(.overflowTrigger-Y8XJnz, .byline-breadcrumbs-overflow-trigger) {
97
+ color: var(--gray-700);
98
+ cursor: pointer;
99
+ font: inherit;
100
+ background: none;
101
+ border: 0;
102
+ border-radius: .25rem;
103
+ justify-content: center;
104
+ align-items: center;
105
+ margin: 0;
106
+ padding: 0 .125rem;
107
+ line-height: 1;
108
+ display: inline-flex;
109
+ }
110
+
111
+ .overflowTrigger-Y8XJnz:hover {
112
+ background-color: var(--gray-100);
113
+ color: var(--gray-900);
114
+ }
115
+
116
+ .byline-breadcrumbs-overflow-trigger:hover {
117
+ background-color: var(--gray-100);
118
+ color: var(--gray-900);
119
+ }
120
+
121
+ .overflowTrigger-Y8XJnz:focus-visible {
122
+ outline: 2px solid var(--gray-400);
123
+ outline-offset: 1px;
124
+ }
125
+
126
+ .byline-breadcrumbs-overflow-trigger:focus-visible {
127
+ outline: 2px solid var(--gray-400);
128
+ outline-offset: 1px;
129
+ }
130
+
131
+ :is(:is([data-theme="dark"], .dark) .overflowTrigger-Y8XJnz, :is([data-theme="dark"], .dark) .byline-breadcrumbs-overflow-trigger) {
132
+ color: var(--gray-400);
133
+ }
134
+
135
+ :is([data-theme="dark"], .dark) .overflowTrigger-Y8XJnz:hover {
136
+ background-color: var(--gray-800);
137
+ color: #fff;
138
+ }
139
+
140
+ :is([data-theme="dark"], .dark) .byline-breadcrumbs-overflow-trigger:hover {
141
+ background-color: var(--gray-800);
142
+ color: #fff;
143
+ }
144
+
145
+ :is(.overflowIcon-FMJ1GK, .byline-breadcrumbs-overflow-icon) {
146
+ width: 1rem;
147
+ height: 1rem;
148
+ }
149
+
150
+ :is(.overflowItem-zVrxKo, .byline-breadcrumbs-overflow-item) {
151
+ color: var(--gray-900);
152
+ cursor: pointer;
153
+ padding: .375rem .625rem;
154
+ font-size: .875rem;
155
+ text-decoration: none;
156
+ }
157
+
158
+ .overflowItem-zVrxKo:hover {
159
+ color: var(--gray-900);
160
+ }
161
+
162
+ .byline-breadcrumbs-overflow-item:hover {
163
+ color: var(--gray-900);
164
+ }
165
+
166
+ :is(:is([data-theme="dark"], .dark) .overflowItem-zVrxKo, :is([data-theme="dark"], .dark) .byline-breadcrumbs-overflow-item) {
167
+ color: var(--gray-300);
168
+ }
169
+
170
+ :is([data-theme="dark"], .dark) .overflowItem-zVrxKo:hover {
171
+ color: #fff;
172
+ }
173
+
174
+ :is([data-theme="dark"], .dark) .byline-breadcrumbs-overflow-item:hover {
175
+ color: #fff;
176
+ }
177
+
@@ -20,7 +20,7 @@ const PreviewLink = ({ collectionPath, doc, adminConfig, locale, className })=>{
20
20
  setBusy(true);
21
21
  try {
22
22
  await enablePreviewModeFn();
23
- window.open(url, '_blank', 'noopener,noreferrer');
23
+ window.location.assign(url);
24
24
  } catch (err) {
25
25
  toastManager.add({
26
26
  title: 'Preview',
@@ -42,7 +42,7 @@ const PreviewLink = ({ collectionPath, doc, adminConfig, locale, className })=>{
42
42
  variant: "text",
43
43
  disabled: busy,
44
44
  onClick: handleClick,
45
- "aria-label": "Open preview in new tab",
45
+ "aria-label": "Open preview",
46
46
  title: "Preview",
47
47
  children: /*#__PURE__*/ jsx(ExternalLinkIcon, {
48
48
  width: "20px",
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "private": false,
4
4
  "type": "module",
5
5
  "license": "MPL-2.0",
6
- "version": "2.2.6",
6
+ "version": "2.2.8",
7
7
  "engines": {
8
8
  "node": ">=20.9.0"
9
9
  },
@@ -107,12 +107,12 @@
107
107
  "react-swipeable": "^7.0.2",
108
108
  "uuid": "^14.0.0",
109
109
  "zod": "^4.4.3",
110
- "@byline/auth": "2.2.6",
111
- "@byline/client": "2.2.6",
112
- "@byline/core": "2.2.6",
113
- "@byline/ui": "2.2.6",
114
- "@byline/ai": "2.2.6",
115
- "@byline/admin": "2.2.6"
110
+ "@byline/admin": "2.2.8",
111
+ "@byline/ai": "2.2.8",
112
+ "@byline/core": "2.2.8",
113
+ "@byline/auth": "2.2.8",
114
+ "@byline/ui": "2.2.8",
115
+ "@byline/client": "2.2.8"
116
116
  },
117
117
  "peerDependencies": {
118
118
  "@tanstack/react-router": "^1.167.0",
@@ -37,6 +37,8 @@
37
37
  display: flex;
38
38
  align-items: center;
39
39
  gap: 1rem;
40
+ flex: 1 1 auto;
41
+ min-width: 0;
40
42
  }
41
43
 
42
44
  .right,
@@ -44,6 +46,7 @@
44
46
  display: flex;
45
47
  align-items: center;
46
48
  gap: 1rem;
49
+ flex: 0 0 auto;
47
50
  font-size: 0.875rem;
48
51
  font-weight: 400;
49
52
  }
@@ -2,23 +2,31 @@
2
2
  * Breadcrumbs — admin app-bar trail.
3
3
  *
4
4
  * Override handles:
5
- * .byline-breadcrumbs — outer <nav>
6
- * .byline-breadcrumbs-list — inline <ul>
7
- * .byline-breadcrumbs-item — each <li>
8
- * .byline-breadcrumbs-link — clickable home + intermediate links
9
- * .byline-breadcrumbs-leaf — current-page (final) span
10
- * .byline-breadcrumbs-icon — chevron / home icons
5
+ * .byline-breadcrumbs — outer <nav>
6
+ * .byline-breadcrumbs-list — inline <ul>
7
+ * .byline-breadcrumbs-item — each <li>
8
+ * .byline-breadcrumbs-link — clickable home + intermediate links
9
+ * .byline-breadcrumbs-leaf — current-page (final) span
10
+ * .byline-breadcrumbs-icon — chevron / home icons
11
+ * .byline-breadcrumbs-overflow-trigger — "…" button shown when items collapse
12
+ * .byline-breadcrumbs-overflow-icon — ellipsis icon inside the trigger
13
+ * .byline-breadcrumbs-overflow-item — each link inside the overflow dropdown
11
14
  */
12
15
 
13
16
  .nav,
14
17
  :global(.byline-breadcrumbs) {
15
18
  display: flex;
19
+ position: relative;
20
+ min-width: 0;
21
+ flex: 1 1 auto;
22
+ overflow: hidden;
16
23
  }
17
24
 
18
25
  .list,
19
26
  :global(.byline-breadcrumbs-list) {
20
27
  display: inline-flex;
21
- flex-wrap: wrap;
28
+ flex-wrap: nowrap;
29
+ white-space: nowrap;
22
30
  align-items: center;
23
31
  list-style: none;
24
32
  margin: 0;
@@ -26,12 +34,24 @@
26
34
  gap: 0.25rem;
27
35
  }
28
36
 
37
+ /* Hidden measurement layer: mirrors all items so we can read their widths
38
+ without affecting layout. Positioned absolutely with visibility hidden so
39
+ it takes up no flow space and isn't focusable / clickable. */
40
+ .measure {
41
+ position: absolute;
42
+ top: 0;
43
+ left: 0;
44
+ visibility: hidden;
45
+ pointer-events: none;
46
+ }
47
+
29
48
  .item,
30
49
  :global(.byline-breadcrumbs-item) {
31
50
  display: inline-flex;
32
51
  align-items: center;
33
52
  margin: 0;
34
53
  padding: 0;
54
+ flex: 0 0 auto;
35
55
  }
36
56
 
37
57
  .link,
@@ -91,3 +111,67 @@
91
111
  :is([data-theme="dark"], :global(.dark)) :global(.byline-breadcrumbs-chevron-current) {
92
112
  color: var(--gray-600);
93
113
  }
114
+
115
+ .overflowTrigger,
116
+ :global(.byline-breadcrumbs-overflow-trigger) {
117
+ display: inline-flex;
118
+ align-items: center;
119
+ justify-content: center;
120
+ margin: 0;
121
+ padding: 0 0.125rem;
122
+ background: transparent;
123
+ border: 0;
124
+ border-radius: 0.25rem;
125
+ color: var(--gray-700);
126
+ cursor: pointer;
127
+ font: inherit;
128
+ line-height: 1;
129
+ }
130
+ .overflowTrigger:hover,
131
+ :global(.byline-breadcrumbs-overflow-trigger:hover) {
132
+ background-color: var(--gray-100);
133
+ color: var(--gray-900);
134
+ }
135
+ .overflowTrigger:focus-visible,
136
+ :global(.byline-breadcrumbs-overflow-trigger:focus-visible) {
137
+ outline: 2px solid var(--gray-400);
138
+ outline-offset: 1px;
139
+ }
140
+
141
+ :is([data-theme="dark"], :global(.dark)) .overflowTrigger,
142
+ :is([data-theme="dark"], :global(.dark)) :global(.byline-breadcrumbs-overflow-trigger) {
143
+ color: var(--gray-400);
144
+ }
145
+ :is([data-theme="dark"], :global(.dark)) .overflowTrigger:hover,
146
+ :is([data-theme="dark"], :global(.dark)) :global(.byline-breadcrumbs-overflow-trigger:hover) {
147
+ background-color: var(--gray-800);
148
+ color: #ffffff;
149
+ }
150
+
151
+ .overflowIcon,
152
+ :global(.byline-breadcrumbs-overflow-icon) {
153
+ width: 1rem;
154
+ height: 1rem;
155
+ }
156
+
157
+ .overflowItem,
158
+ :global(.byline-breadcrumbs-overflow-item) {
159
+ padding: 0.375rem 0.625rem;
160
+ font-size: 0.875rem;
161
+ color: var(--gray-900);
162
+ text-decoration: none;
163
+ cursor: pointer;
164
+ }
165
+ .overflowItem:hover,
166
+ :global(.byline-breadcrumbs-overflow-item:hover) {
167
+ color: var(--gray-900);
168
+ }
169
+
170
+ :is([data-theme="dark"], :global(.dark)) .overflowItem,
171
+ :is([data-theme="dark"], :global(.dark)) :global(.byline-breadcrumbs-overflow-item) {
172
+ color: var(--gray-300);
173
+ }
174
+ :is([data-theme="dark"], :global(.dark)) .overflowItem:hover,
175
+ :is([data-theme="dark"], :global(.dark)) :global(.byline-breadcrumbs-overflow-item:hover) {
176
+ color: #ffffff;
177
+ }
@@ -1,3 +1,5 @@
1
+ 'use client'
2
+
1
3
  /**
2
4
  * This Source Code is subject to the terms of the Mozilla Public
3
5
  * License, v. 2.0. If a copy of the MPL was not distributed with this
@@ -6,12 +8,19 @@
6
8
  * Copyright (c) Infonomic Company Limited
7
9
  */
8
10
 
11
+ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
12
+
13
+ import { Dropdown, EllipsisIcon } from '@byline/ui/react'
9
14
  import cx from 'classnames'
10
15
 
11
16
  import { Link } from '../loose-router.js'
12
17
  import styles from './breadcrumbs.module.css'
13
18
  import type { Breadcrumb } from './@types.js'
14
19
 
20
+ const useIsoLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect
21
+
22
+ const MAX_LABEL_LENGTH = 20
23
+
15
24
  function truncate(str: string, length: number, useWordBoundary = true, useSuffix = true): string {
16
25
  if (str == null || str.length <= length) return str
17
26
  const subString = str.slice(0, length - 2)
@@ -30,67 +39,219 @@ export function Breadcrumbs({
30
39
  homeLabel?: string
31
40
  homePath?: string
32
41
  }): React.JSX.Element {
42
+ const navRef = useRef<HTMLElement | null>(null)
43
+ const measureRef = useRef<HTMLUListElement | null>(null)
44
+ // visibleIndices = indices into breadcrumbs[] that are shown inline.
45
+ // Anything missing from this set is rolled into the overflow dropdown.
46
+ // Default to "all visible" so SSR and pre-measurement paints look right.
47
+ const [visibleIndices, setVisibleIndices] = useState<number[]>(() => breadcrumbs.map((_, i) => i))
48
+
49
+ // Reset visibility when breadcrumbs change; the measurement effect below
50
+ // will collapse again on the next layout tick if needed.
51
+ useIsoLayoutEffect(() => {
52
+ setVisibleIndices(breadcrumbs.map((_, i) => i))
53
+ }, [breadcrumbs])
54
+
55
+ useIsoLayoutEffect(() => {
56
+ const nav = navRef.current
57
+ const measure = measureRef.current
58
+ if (!nav || !measure) return
59
+
60
+ const compute = () => {
61
+ // measurement layer order: [home, ...breadcrumbs..., overflow-trigger]
62
+ const children = Array.from(measure.children) as HTMLElement[]
63
+ if (children.length !== breadcrumbs.length + 2) return
64
+
65
+ const containerWidth = nav.clientWidth
66
+ const homeWidth = children[0].offsetWidth
67
+ const triggerWidth = children[children.length - 1].offsetWidth
68
+ const itemWidths = children.slice(1, -1).map((el) => el.offsetWidth)
69
+ const gap = Number.parseFloat(getComputedStyle(measure).gap) || 4
70
+
71
+ const n = itemWidths.length
72
+ let next: number[]
73
+
74
+ // n <= 1: just Home + (optional) leaf — never collapse.
75
+ // n === 2: per design, two-segment trails (Home > Dashboard > Leaf) never collapse.
76
+ const allTotal = homeWidth + itemWidths.reduce((a, b) => a + b, 0) + n * gap
77
+ if (n <= 2 || allTotal <= containerWidth) {
78
+ next = itemWidths.map((_, i) => i)
79
+ } else {
80
+ // Always preserve Dashboard (idx 0) and Leaf (idx n-1).
81
+ // Then add middle items greedily from leaf-adjacent backward.
82
+ const visible = new Set<number>([0, n - 1])
83
+ // Gap accounting: between home/dashboard, dashboard/trigger,
84
+ // trigger/leaf, plus the leading gap before home.
85
+ const baselineGaps = 4 * gap
86
+ let used = homeWidth + itemWidths[0] + triggerWidth + itemWidths[n - 1] + baselineGaps
87
+
88
+ if (used > containerWidth) {
89
+ // Even Home + Dashboard + … + Leaf doesn't fit. Push Dashboard
90
+ // into the overflow too; keep Home + … + Leaf as the minimum.
91
+ visible.delete(0)
92
+ used = homeWidth + triggerWidth + itemWidths[n - 1] + 3 * gap
93
+ }
94
+
95
+ for (let i = n - 2; i >= 1; i--) {
96
+ if (visible.has(i)) continue
97
+ const cost = itemWidths[i] + gap
98
+ if (used + cost > containerWidth) break
99
+ used += cost
100
+ visible.add(i)
101
+ }
102
+ next = [...visible].sort((a, b) => a - b)
103
+ }
104
+
105
+ setVisibleIndices((prev) => {
106
+ if (prev.length === next.length && prev.every((v, i) => v === next[i])) return prev
107
+ return next
108
+ })
109
+ }
110
+
111
+ compute()
112
+ const ro = new ResizeObserver(compute)
113
+ ro.observe(nav)
114
+ return () => ro.disconnect()
115
+ }, [breadcrumbs])
116
+
117
+ const overflowed = useMemo(() => {
118
+ const visible = new Set(visibleIndices)
119
+ return breadcrumbs.filter((_, i) => !visible.has(i))
120
+ }, [breadcrumbs, visibleIndices])
121
+
122
+ // Walk source order, emitting visible items inline; the first time we hit
123
+ // an overflowed index, drop the dropdown trigger in its place.
124
+ const visibleSet = new Set(visibleIndices)
125
+ const lastIndex = breadcrumbs.length - 1
126
+ let overflowEmitted = false
127
+ const rendered: React.ReactNode[] = []
128
+ for (let i = 0; i < breadcrumbs.length; i++) {
129
+ if (visibleSet.has(i)) {
130
+ rendered.push(renderBreadcrumb(breadcrumbs[i], i === lastIndex))
131
+ } else if (!overflowEmitted) {
132
+ overflowEmitted = true
133
+ rendered.push(<OverflowDropdown key="__overflow__" items={overflowed} />)
134
+ }
135
+ }
136
+
33
137
  return (
34
- <nav aria-label="Breadcrumb" className={cx('byline-breadcrumbs', styles.nav, className)}>
138
+ <nav
139
+ ref={navRef}
140
+ aria-label="Breadcrumb"
141
+ className={cx('byline-breadcrumbs', styles.nav, className)}
142
+ >
35
143
  <ul className={cx('byline-breadcrumbs-list', styles.list)}>
144
+ <HomeItem homePath={homePath} homeLabel={homeLabel} />
145
+ {rendered}
146
+ </ul>
147
+ {/* Hidden measurement layer — always renders every item plus the
148
+ overflow trigger placeholder so we can read accurate widths. */}
149
+ <ul ref={measureRef} aria-hidden className={cx(styles.list, styles.measure)}>
150
+ <HomeItem homePath={homePath} homeLabel={homeLabel} />
151
+ {breadcrumbs.map((b, i) => renderBreadcrumb(b, i === lastIndex))}
36
152
  <li className={cx('byline-breadcrumbs-item', styles.item)}>
37
- <Link to={homePath as string} className={cx('byline-breadcrumbs-link', styles.link)}>
38
- <svg
39
- role="presentation"
40
- className={cx('byline-breadcrumbs-home-icon', styles.homeIcon)}
41
- fill="currentColor"
42
- viewBox="0 0 20 20"
43
- xmlns="http://www.w3.org/2000/svg"
44
- >
45
- <path d="M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" />
46
- </svg>
47
- {homeLabel}
48
- </Link>
153
+ <ChevronIcon />
154
+ <span className={cx('byline-breadcrumbs-overflow-trigger', styles.overflowTrigger)}>
155
+ <EllipsisIcon className={cx('byline-breadcrumbs-overflow-icon', styles.overflowIcon)} />
156
+ </span>
49
157
  </li>
50
- {breadcrumbs != null &&
51
- breadcrumbs.length > 0 &&
52
- breadcrumbs.map((breadcrumb, index) => {
53
- const isLeaf = index === breadcrumbs.length - 1
54
- return (
55
- <li
56
- key={breadcrumb.href}
57
- aria-current={isLeaf ? 'page' : undefined}
58
- className={cx('byline-breadcrumbs-item', styles.item)}
59
- >
60
- <div className={cx('byline-breadcrumbs-item-row', styles.item)}>
61
- <svg
62
- role="presentation"
63
- className={cx('byline-breadcrumbs-chevron', styles.chevron, {
64
- 'byline-breadcrumbs-chevron-current': isLeaf,
65
- [styles.chevronCurrent]: isLeaf,
66
- })}
67
- fill="currentColor"
68
- viewBox="0 0 20 20"
69
- xmlns="http://www.w3.org/2000/svg"
70
- >
71
- <path
72
- fillRule="evenodd"
73
- d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
74
- clipRule="evenodd"
75
- />
76
- </svg>
77
- {isLeaf ? (
78
- <span className={cx('byline-breadcrumbs-leaf', styles.leaf)}>
79
- {truncate(breadcrumb.label, 20, true)}
80
- </span>
81
- ) : (
82
- <Link
83
- to={breadcrumb.href as string}
84
- className={cx('byline-breadcrumbs-link', styles.link)}
85
- >
86
- {truncate(breadcrumb.label, 20, true)}
87
- </Link>
88
- )}
89
- </div>
90
- </li>
91
- )
92
- })}
93
158
  </ul>
94
159
  </nav>
95
160
  )
96
161
  }
162
+
163
+ function renderBreadcrumb(breadcrumb: Breadcrumb, isLeaf: boolean): React.JSX.Element {
164
+ return (
165
+ <li
166
+ key={breadcrumb.href}
167
+ aria-current={isLeaf ? 'page' : undefined}
168
+ className={cx('byline-breadcrumbs-item', styles.item)}
169
+ >
170
+ <ChevronIcon isLeaf={isLeaf} />
171
+ {isLeaf ? (
172
+ <span className={cx('byline-breadcrumbs-leaf', styles.leaf)}>
173
+ {truncate(breadcrumb.label, MAX_LABEL_LENGTH, true)}
174
+ </span>
175
+ ) : (
176
+ <Link to={breadcrumb.href as string} className={cx('byline-breadcrumbs-link', styles.link)}>
177
+ {truncate(breadcrumb.label, MAX_LABEL_LENGTH, true)}
178
+ </Link>
179
+ )}
180
+ </li>
181
+ )
182
+ }
183
+
184
+ function HomeItem({
185
+ homePath,
186
+ homeLabel,
187
+ }: {
188
+ homePath: string
189
+ homeLabel: string
190
+ }): React.JSX.Element {
191
+ return (
192
+ <li className={cx('byline-breadcrumbs-item', styles.item)}>
193
+ <Link to={homePath as string} className={cx('byline-breadcrumbs-link', styles.link)}>
194
+ <svg
195
+ role="presentation"
196
+ className={cx('byline-breadcrumbs-home-icon', styles.homeIcon)}
197
+ fill="currentColor"
198
+ viewBox="0 0 20 20"
199
+ xmlns="http://www.w3.org/2000/svg"
200
+ >
201
+ <path d="M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" />
202
+ </svg>
203
+ {homeLabel}
204
+ </Link>
205
+ </li>
206
+ )
207
+ }
208
+
209
+ function ChevronIcon({ isLeaf = false }: { isLeaf?: boolean }): React.JSX.Element {
210
+ return (
211
+ <svg
212
+ role="presentation"
213
+ className={cx('byline-breadcrumbs-chevron', styles.chevron, {
214
+ 'byline-breadcrumbs-chevron-current': isLeaf,
215
+ [styles.chevronCurrent]: isLeaf,
216
+ })}
217
+ fill="currentColor"
218
+ viewBox="0 0 20 20"
219
+ xmlns="http://www.w3.org/2000/svg"
220
+ >
221
+ <path
222
+ fillRule="evenodd"
223
+ d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
224
+ clipRule="evenodd"
225
+ />
226
+ </svg>
227
+ )
228
+ }
229
+
230
+ function OverflowDropdown({ items }: { items: Breadcrumb[] }): React.JSX.Element {
231
+ return (
232
+ <li className={cx('byline-breadcrumbs-item', styles.item)}>
233
+ <ChevronIcon />
234
+ <Dropdown.Root>
235
+ <Dropdown.Trigger
236
+ aria-label="Show hidden breadcrumbs"
237
+ className={cx('byline-breadcrumbs-overflow-trigger', styles.overflowTrigger)}
238
+ >
239
+ <EllipsisIcon className={cx('byline-breadcrumbs-overflow-icon', styles.overflowIcon)} />
240
+ </Dropdown.Trigger>
241
+ <Dropdown.Portal>
242
+ <Dropdown.Content sideOffset={5} align="start">
243
+ {items.map((item) => (
244
+ <Dropdown.Item
245
+ key={item.href}
246
+ className={cx('byline-breadcrumbs-overflow-item', styles.overflowItem)}
247
+ render={<Link to={item.href as string} />}
248
+ >
249
+ {item.label}
250
+ </Dropdown.Item>
251
+ ))}
252
+ </Dropdown.Content>
253
+ </Dropdown.Portal>
254
+ </Dropdown.Root>
255
+ </li>
256
+ )
257
+ }
@@ -14,8 +14,8 @@
14
14
  * on the admin's session — so the front-end host's viewer client
15
15
  * starts surfacing draft versions for this admin's subsequent
16
16
  * requests.
17
- * 2. Opens the document's preview URL in a new tab via
18
- * `window.open(url, '_blank', 'noopener,noreferrer')`.
17
+ * 2. Navigates the current tab to the document's preview URL via
18
+ * `window.location.assign(url)`.
19
19
  *
20
20
  * The preview URL comes from `CollectionAdminConfig.preview.url(doc, ctx)`
21
21
  * when configured; otherwise it falls back to the conventional
@@ -101,13 +101,11 @@ export const PreviewLink = ({
101
101
  if (busy) return
102
102
  setBusy(true)
103
103
  try {
104
- // Enable preview mode for the admin's browser session before opening
105
- // the URL. The viewer client on the front-end host reads the cookie
106
- // on subsequent requests and elevates the read context.
104
+ // Enable preview mode for the admin's browser session before
105
+ // navigating. The viewer client on the front-end host reads the
106
+ // cookie on subsequent requests and elevates the read context.
107
107
  await enablePreviewModeFn()
108
- // `noopener,noreferrer` so the opened tab can't reach back into
109
- // the admin window via `window.opener`.
110
- window.open(url, '_blank', 'noopener,noreferrer')
108
+ window.location.assign(url)
111
109
  } catch (err) {
112
110
  toastManager.add({
113
111
  title: 'Preview',
@@ -131,7 +129,7 @@ export const PreviewLink = ({
131
129
  variant="text"
132
130
  disabled={busy}
133
131
  onClick={handleClick}
134
- aria-label="Open preview in new tab"
132
+ aria-label="Open preview"
135
133
  title="Preview"
136
134
  >
137
135
  <ExternalLinkIcon width="20px" height="20px" className="byline-preview-link-icon" />