@adecore/ui 0.24.0 → 0.24.1

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/dist/theme.css CHANGED
@@ -501,12 +501,13 @@
501
501
  /* A strip that scrolls sideways fades out at the edge that still hides something, and only there,
502
502
  so a strip that fits keeps its first and last item sharp. The edges are set by whoever scrolls. */
503
503
  .scroll-fade-x {
504
+ --fade-size: 24px;
504
505
  --fade-start: 0px;
505
506
  --fade-end: 0px;
506
507
  mask-image: linear-gradient(to right, transparent, #000 var(--fade-start), #000 calc(100% - var(--fade-end)), transparent);
507
508
  }
508
- .scroll-fade-x[data-fade-start] { --fade-start: 24px; }
509
- .scroll-fade-x[data-fade-end] { --fade-end: 24px; }
509
+ .scroll-fade-x[data-fade-start] { --fade-start: var(--fade-size); }
510
+ .scroll-fade-x[data-fade-end] { --fade-end: var(--fade-size); }
510
511
  /* The same for a column under a fixed header: its top fades once something has scrolled under it. */
511
512
  .scroll-fade-top {
512
513
  --fade-start: 0px;
@@ -7,7 +7,7 @@ import { useTranslation } from 'react-i18next';
7
7
  import { Icon } from '../Icon.js';
8
8
  import { IconButton } from '../IconButton.js';
9
9
  import { Tooltip } from '../Tooltip.js';
10
- import { tabGapAt } from './geometry.js';
10
+ import { tabGapAt, tabRevealScrollLeft } from './geometry.js';
11
11
  /* Where an arrow, Home or End moves the selection from `index`, wrapping at either end; null for any other key. */
12
12
  function tabIndexForKey(key, index, count) {
13
13
  switch (key) {
@@ -62,20 +62,44 @@ export function TabStrip({ items, value, onValueChange, renderTab, focused = tru
62
62
  setInsertion(after?.offsetLeft ?? (last ? last.offsetLeft + last.offsetWidth : 0));
63
63
  }
64
64
  }, [insertAt]);
65
- useLayoutEffect(measure);
65
+ // Stays set after the active tab changes, as its width can settle frames later; a person's own scroll ends it.
66
+ const revealing = useRef(false);
67
+ const reveal = () => {
68
+ const element = strip.current;
69
+ const selected = element?.querySelector('[aria-selected="true"]');
70
+ if (!revealing.current || !element || !selected) {
71
+ return;
72
+ }
73
+ const container = selected.closest('[data-document-tab]');
74
+ const tab = container && element.contains(container) ? container : selected;
75
+ const fade = parseFloat(getComputedStyle(element).getPropertyValue('--fade-size')) || 0;
76
+ const bounds = element.getBoundingClientRect();
77
+ const rect = tab.getBoundingClientRect();
78
+ const left = tabRevealScrollLeft(element, rect.left - bounds.left - element.clientLeft + element.scrollLeft, rect.width, fade);
79
+ if (left !== element.scrollLeft) {
80
+ element.scrollLeft = left;
81
+ }
82
+ };
83
+ useLayoutEffect(() => {
84
+ revealing.current = true;
85
+ }, [value]);
86
+ useLayoutEffect(() => {
87
+ reveal();
88
+ measure();
89
+ });
66
90
  useEffect(() => {
67
91
  const element = strip.current;
68
92
  if (!element || typeof ResizeObserver === 'undefined') {
69
93
  return;
70
94
  }
71
- const observer = new ResizeObserver(measure);
95
+ const observer = new ResizeObserver(() => {
96
+ reveal();
97
+ measure();
98
+ });
72
99
  observer.observe(element);
73
100
  element.querySelectorAll('[data-document-tab]').forEach((tab) => observer.observe(tab));
74
101
  return () => observer.disconnect();
75
102
  });
76
- useEffect(() => {
77
- strip.current?.querySelector('[aria-selected="true"]')?.scrollIntoView?.({ block: 'nearest', inline: 'nearest' });
78
- }, [value]);
79
103
  const gap = (event) => {
80
104
  const rects = Array.from(event.currentTarget.querySelectorAll('[data-document-tab]'), (tab) => tab.getBoundingClientRect());
81
105
  return tabGapAt(rects, event.clientX);
@@ -110,10 +134,21 @@ export function TabStrip({ items, value, onValueChange, renderTab, focused = tru
110
134
  },
111
135
  onWheel: (event) => {
112
136
  props.onWheel?.(event);
113
- if (!event.defaultPrevented && !event.deltaX && event.deltaY) {
137
+ if (event.defaultPrevented) {
138
+ return;
139
+ }
140
+ revealing.current = false;
141
+ if (!event.deltaX && event.deltaY) {
114
142
  event.currentTarget.scrollLeft += event.deltaY;
115
143
  }
116
144
  },
145
+ onPointerDown: (event) => {
146
+ props.onPointerDown?.(event);
147
+ // A press on the strip itself grabs its scrollbar, and a touch may pan it.
148
+ if (event.target === event.currentTarget || event.pointerType === 'touch') {
149
+ revealing.current = false;
150
+ }
151
+ },
117
152
  onDragOver: (event) => {
118
153
  props.onDragOver?.(event);
119
154
  if (!event.defaultPrevented) {
@@ -37,3 +37,14 @@ export declare function tabGapAt(rects: readonly {
37
37
  left: number;
38
38
  right: number;
39
39
  }[], x: number): number;
40
+ export interface TabScrollView {
41
+ scrollLeft: number;
42
+ clientWidth: number;
43
+ scrollWidth: number;
44
+ }
45
+ /**
46
+ * The scrollLeft that brings a tab fully into view and clear of the edge fades, or the current one
47
+ * when it already is. The strip only fades an edge it can still scroll toward, so a fade is counted
48
+ * only where the scroll position leaves one. A tab wider than the room between the fades shows its start.
49
+ */
50
+ export declare function tabRevealScrollLeft(view: TabScrollView, start: number, width: number, fade: number): number;
@@ -151,3 +151,22 @@ export function tabGapAt(rects, x) {
151
151
  const index = rects.findIndex((rect) => x < (rect.left + rect.right) / 2);
152
152
  return index < 0 ? rects.length : index;
153
153
  }
154
+ /**
155
+ * The scrollLeft that brings a tab fully into view and clear of the edge fades, or the current one
156
+ * when it already is. The strip only fades an edge it can still scroll toward, so a fade is counted
157
+ * only where the scroll position leaves one. A tab wider than the room between the fades shows its start.
158
+ */
159
+ export function tabRevealScrollLeft(view, start, width, fade) {
160
+ const max = Math.max(0, view.scrollWidth - view.clientWidth);
161
+ const end = start + width;
162
+ const visibleStart = (left) => left + (left > 0 ? fade : 0);
163
+ const visibleEnd = (left) => left + view.clientWidth - (left < max - 1 ? fade : 0);
164
+ const clamp = (left) => Math.round(Math.min(max, Math.max(0, left)));
165
+ if (start >= visibleStart(view.scrollLeft) && end <= visibleEnd(view.scrollLeft)) {
166
+ return view.scrollLeft;
167
+ }
168
+ if (start < visibleStart(view.scrollLeft) || width > view.clientWidth - 2 * fade) {
169
+ return clamp(start - fade);
170
+ }
171
+ return clamp(end + fade - view.clientWidth);
172
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adecore/ui",
3
- "version": "0.24.0",
3
+ "version": "0.24.1",
4
4
  "description": "React components, a theme, formatters and a settings dialog for desktop-grade apps on React 19, Base UI and Tailwind 4.",
5
5
  "license": "FSL-1.1-MIT",
6
6
  "author": "Bas Milius",
package/src/theme.css CHANGED
@@ -501,12 +501,13 @@
501
501
  /* A strip that scrolls sideways fades out at the edge that still hides something, and only there,
502
502
  so a strip that fits keeps its first and last item sharp. The edges are set by whoever scrolls. */
503
503
  .scroll-fade-x {
504
+ --fade-size: 24px;
504
505
  --fade-start: 0px;
505
506
  --fade-end: 0px;
506
507
  mask-image: linear-gradient(to right, transparent, #000 var(--fade-start), #000 calc(100% - var(--fade-end)), transparent);
507
508
  }
508
- .scroll-fade-x[data-fade-start] { --fade-start: 24px; }
509
- .scroll-fade-x[data-fade-end] { --fade-end: 24px; }
509
+ .scroll-fade-x[data-fade-start] { --fade-start: var(--fade-size); }
510
+ .scroll-fade-x[data-fade-end] { --fade-end: var(--fade-size); }
510
511
  /* The same for a column under a fixed header: its top fades once something has scrolled under it. */
511
512
  .scroll-fade-top {
512
513
  --fade-start: 0px;
@@ -8,6 +8,7 @@ import {
8
8
  useState,
9
9
  type DragEvent,
10
10
  type MouseEvent,
11
+ type PointerEvent,
11
12
  type ReactNode,
12
13
  type UIEvent,
13
14
  type WheelEvent
@@ -19,7 +20,7 @@ import { useTranslation } from 'react-i18next';
19
20
  import { Icon } from '../Icon.tsx';
20
21
  import { IconButton, type IconButtonProps } from '../IconButton.tsx';
21
22
  import { Tooltip } from '../Tooltip.tsx';
22
- import { tabGapAt } from './geometry.ts';
23
+ import { tabGapAt, tabRevealScrollLeft } from './geometry.ts';
23
24
 
24
25
  /* Where an arrow, Home or End moves the selection from `index`, wrapping at either end; null for any other key. */
25
26
  function tabIndexForKey(key: string, index: number, count: number): number | null {
@@ -123,20 +124,44 @@ export function TabStrip({
123
124
  setInsertion(after?.offsetLeft ?? (last ? last.offsetLeft + last.offsetWidth : 0));
124
125
  }
125
126
  }, [insertAt]);
126
- useLayoutEffect(measure);
127
+ // Stays set after the active tab changes, as its width can settle frames later; a person's own scroll ends it.
128
+ const revealing = useRef(false);
129
+ const reveal = (): void => {
130
+ const element = strip.current;
131
+ const selected = element?.querySelector<HTMLElement>('[aria-selected="true"]');
132
+ if (!revealing.current || !element || !selected) {
133
+ return;
134
+ }
135
+ const container = selected.closest<HTMLElement>('[data-document-tab]');
136
+ const tab = container && element.contains(container) ? container : selected;
137
+ const fade = parseFloat(getComputedStyle(element).getPropertyValue('--fade-size')) || 0;
138
+ const bounds = element.getBoundingClientRect();
139
+ const rect = tab.getBoundingClientRect();
140
+ const left = tabRevealScrollLeft(element, rect.left - bounds.left - element.clientLeft + element.scrollLeft, rect.width, fade);
141
+ if (left !== element.scrollLeft) {
142
+ element.scrollLeft = left;
143
+ }
144
+ };
145
+ useLayoutEffect(() => {
146
+ revealing.current = true;
147
+ }, [value]);
148
+ useLayoutEffect(() => {
149
+ reveal();
150
+ measure();
151
+ });
127
152
  useEffect(() => {
128
153
  const element = strip.current;
129
154
  if (!element || typeof ResizeObserver === 'undefined') {
130
155
  return;
131
156
  }
132
- const observer = new ResizeObserver(measure);
157
+ const observer = new ResizeObserver(() => {
158
+ reveal();
159
+ measure();
160
+ });
133
161
  observer.observe(element);
134
162
  element.querySelectorAll('[data-document-tab]').forEach((tab) => observer.observe(tab));
135
163
  return () => observer.disconnect();
136
164
  });
137
- useEffect(() => {
138
- strip.current?.querySelector('[aria-selected="true"]')?.scrollIntoView?.({ block: 'nearest', inline: 'nearest' });
139
- }, [value]);
140
165
  const gap = (event: DragEvent<HTMLDivElement>): number => {
141
166
  const rects = Array.from(event.currentTarget.querySelectorAll('[data-document-tab]'), (tab) => tab.getBoundingClientRect());
142
167
  return tabGapAt(rects, event.clientX);
@@ -171,10 +196,21 @@ export function TabStrip({
171
196
  },
172
197
  onWheel: (event: WheelEvent<HTMLDivElement>) => {
173
198
  props.onWheel?.(event);
174
- if (!event.defaultPrevented && !event.deltaX && event.deltaY) {
199
+ if (event.defaultPrevented) {
200
+ return;
201
+ }
202
+ revealing.current = false;
203
+ if (!event.deltaX && event.deltaY) {
175
204
  event.currentTarget.scrollLeft += event.deltaY;
176
205
  }
177
206
  },
207
+ onPointerDown: (event: PointerEvent<HTMLDivElement>) => {
208
+ props.onPointerDown?.(event);
209
+ // A press on the strip itself grabs its scrollbar, and a touch may pan it.
210
+ if (event.target === event.currentTarget || event.pointerType === 'touch') {
211
+ revealing.current = false;
212
+ }
213
+ },
178
214
  onDragOver: (event: DragEvent<HTMLDivElement>) => {
179
215
  props.onDragOver?.(event);
180
216
  if (!event.defaultPrevented) {
@@ -188,3 +188,29 @@ export function tabGapAt(rects: readonly { left: number; right: number }[], x: n
188
188
  const index = rects.findIndex((rect) => x < (rect.left + rect.right) / 2);
189
189
  return index < 0 ? rects.length : index;
190
190
  }
191
+
192
+ export interface TabScrollView {
193
+ scrollLeft: number;
194
+ clientWidth: number;
195
+ scrollWidth: number;
196
+ }
197
+
198
+ /**
199
+ * The scrollLeft that brings a tab fully into view and clear of the edge fades, or the current one
200
+ * when it already is. The strip only fades an edge it can still scroll toward, so a fade is counted
201
+ * only where the scroll position leaves one. A tab wider than the room between the fades shows its start.
202
+ */
203
+ export function tabRevealScrollLeft(view: TabScrollView, start: number, width: number, fade: number): number {
204
+ const max = Math.max(0, view.scrollWidth - view.clientWidth);
205
+ const end = start + width;
206
+ const visibleStart = (left: number): number => left + (left > 0 ? fade : 0);
207
+ const visibleEnd = (left: number): number => left + view.clientWidth - (left < max - 1 ? fade : 0);
208
+ const clamp = (left: number): number => Math.round(Math.min(max, Math.max(0, left)));
209
+ if (start >= visibleStart(view.scrollLeft) && end <= visibleEnd(view.scrollLeft)) {
210
+ return view.scrollLeft;
211
+ }
212
+ if (start < visibleStart(view.scrollLeft) || width > view.clientWidth - 2 * fade) {
213
+ return clamp(start - fade);
214
+ }
215
+ return clamp(end + fade - view.clientWidth);
216
+ }