@swan-io/lake 2.5.0 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swan-io/lake",
3
- "version": "2.5.0",
3
+ "version": "2.6.0",
4
4
  "engines": {
5
5
  "node": ">=18.0.0",
6
6
  "yarn": "^1.22.0"
@@ -7,13 +7,21 @@ type Tab = {
7
7
  icon?: IconName;
8
8
  withSeparator?: boolean;
9
9
  count?: number;
10
+ } | {
11
+ label: string;
12
+ icon?: IconName;
13
+ withSeparator?: boolean;
14
+ count?: number;
15
+ id: string;
10
16
  };
11
17
  type Props = {
18
+ activeTabId?: string;
19
+ onChange?: (id: string) => void;
12
20
  tabs: Tab[];
13
21
  otherLabel: string;
14
22
  hideIfSingleItem?: boolean;
15
23
  padding?: SpacingValue;
16
24
  sticky?: boolean;
17
25
  };
18
- export declare const TabView: ({ tabs, otherLabel, hideIfSingleItem, sticky, padding, }: Props) => import("react/jsx-runtime").JSX.Element | null;
26
+ export declare const TabView: ({ tabs, otherLabel, hideIfSingleItem, sticky, padding, activeTabId, onChange, }: Props) => import("react/jsx-runtime").JSX.Element | null;
19
27
  export {};
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { useLocation } from "@swan-io/chicane";
3
3
  import { Fragment, forwardRef, useCallback, useEffect, useLayoutEffect, useMemo, useReducer, useRef, useState, } from "react";
4
- import { StyleSheet, Text, View } from "react-native";
4
+ import { StyleSheet, Text, View, } from "react-native";
5
5
  import { P, match } from "ts-pattern";
6
6
  import { animations, backgroundColor, colors, negativeSpacings, radii, shadows, spacings, texts, } from "../constants/design";
7
7
  import { useHover } from "../hooks/useHover";
@@ -121,24 +121,38 @@ const styles = StyleSheet.create({
121
121
  top: -1,
122
122
  },
123
123
  });
124
- const Dropdown = ({ tabs, onHoverStart, onHoverEnd, onLinkFocus, onLinkBlur, onLinkPress, }) => {
124
+ const TabViewLink = forwardRef(({ children, style, tab, onChange, activeTabId, onBlur, onFocus, onPress }, ref) => {
125
+ return match(tab)
126
+ .with({ url: P.string }, ({ url }) => (_jsx(Link, { ref: ref, to: url, style: style, onFocus: onFocus, onBlur: onBlur, onPress: onPress, children: children })))
127
+ .with({ id: P.string }, ({ id }) => {
128
+ const tabId = getTabId(tab);
129
+ const isActive = tabId === activeTabId;
130
+ return (_jsx(PressableText, { ref: ref, style: state => style({ ...state, active: isActive }), onPress: () => {
131
+ onChange?.(id);
132
+ onPress?.();
133
+ }, onFocus: onFocus, onBlur: onBlur, children: children }));
134
+ })
135
+ .exhaustive();
136
+ });
137
+ const Dropdown = ({ tabs, onHoverStart, onHoverEnd, onLinkFocus, onLinkBlur, onLinkPress, activeTabId, onChange, }) => {
125
138
  const containerRef = useRef(null);
126
139
  useHover(containerRef, {
127
140
  onHoverStart,
128
141
  onHoverEnd,
129
142
  });
130
- return (_jsx(View, { role: "menu", style: styles.dropdown, ref: containerRef, children: tabs.map(({ url, label }) => {
131
- return (_jsx(Link, { to: url, onFocus: onLinkFocus, onBlur: onLinkBlur, onPress: onLinkPress, role: "menuitem", ariaCurrentValue: "location", style: ({ active, hovered }) => [
143
+ return (_jsx(View, { role: "menu", style: styles.dropdown, ref: containerRef, children: tabs.map(tab => {
144
+ const tabId = getTabId(tab);
145
+ return (_jsx(TabViewLink, { onChange: onChange, activeTabId: activeTabId, tab: tab, onFocus: onLinkFocus, onBlur: onLinkBlur, onPress: onLinkPress, role: "menuitem", ariaCurrentValue: "location", style: ({ active, hovered }) => [
132
146
  styles.dropdownLink,
133
147
  active && styles.dropdownLinkTextActive,
134
148
  hovered && styles.dropdownLinkTextHovered,
135
- ], children: label }, url));
149
+ ], children: tab.label }, tabId));
136
150
  }) }));
137
151
  };
138
152
  const SHOULD_AUTOFOCUS = new Set(["ForcedOpen", "OpenFromFocus"]);
139
153
  const SHOULD_OPEN = new Set(["Open", "ForcedOpen", "OpenFromFocus"]);
140
154
  const SHOULD_LOCK_FOCUS = new Set(["ForcedOpen"]);
141
- const DropdownItems = forwardRef(({ tabs, otherLabel, currentUrl }, ref) => {
155
+ const DropdownItems = forwardRef(({ tabs, otherLabel, currentUrl, activeTabId, onChange }, ref) => {
142
156
  const [openingStatus, dispatch] = useReducer((state, action) => {
143
157
  return match([action, state])
144
158
  .returnType()
@@ -251,13 +265,21 @@ const DropdownItems = forwardRef(({ tabs, otherLabel, currentUrl }, ref) => {
251
265
  onHoverEnd,
252
266
  });
253
267
  const mergedRef = useMergeRefs(containerRef, ref);
254
- const activeTab = tabs.find(({ url }) => url === currentUrl);
268
+ const activeTab = tabs.find(tab => isTabActive({ activeTabId, currentLocationURL: currentUrl, tab }));
255
269
  return (_jsxs(View, { style: styles.dropdownHandleContainer, ref: mergedRef, children: [_jsxs(PressableText, { ref: handleRef, role: "button", "aria-expanded": shouldOpen, "aria-haspopup": "true", onFocus: onHandleFocus, onBlur: onAnyBlur, onPress: onPress, style: ({ hovered }) => [
256
270
  styles.link,
257
271
  isNotNullish(activeTab) ? styles.activeLink : hovered ? styles.hoveredLink : null,
258
- ], children: [_jsx(Text, { children: otherLabel }), _jsx(Space, { width: 8 }), _jsx(Text, { style: styles.count, children: tabs.length }), _jsx(Space, { width: 4 }), _jsx(Icon, { name: "chevron-down-filled", size: 12 })] }), _jsx(TransitionView, { ...animations.fadeAndSlideInFromBottom, style: styles.dropdownPlacement, children: shouldOpen ? (_jsx(FocusTrap, { autoFocus: shouldAutoFocus, focusLock: shouldLockFocus, returnFocus: shouldLockFocus, onClickOutside: onPressOutside, onEscapeKey: shouldLockFocus ? onEscapeKey : undefined, children: _jsx(Dropdown, { tabs: tabs, onHoverStart: onHoverStart, onHoverEnd: onHoverEnd, onLinkFocus: onLinkFocus, onLinkBlur: onAnyBlur, onLinkPress: onEscapeKey }) })) : null })] }));
272
+ ], children: [_jsx(Text, { children: otherLabel }), _jsx(Space, { width: 8 }), _jsx(Text, { style: styles.count, children: tabs.length }), _jsx(Space, { width: 4 }), _jsx(Icon, { name: "chevron-down-filled", size: 12 })] }), _jsx(TransitionView, { ...animations.fadeAndSlideInFromBottom, style: styles.dropdownPlacement, children: shouldOpen ? (_jsx(FocusTrap, { autoFocus: shouldAutoFocus, focusLock: shouldLockFocus, returnFocus: shouldLockFocus, onClickOutside: onPressOutside, onEscapeKey: shouldLockFocus ? onEscapeKey : undefined, children: _jsx(Dropdown, { onChange: onChange, activeTabId: activeTabId, tabs: tabs, onHoverStart: onHoverStart, onHoverEnd: onHoverEnd, onLinkFocus: onLinkFocus, onLinkBlur: onAnyBlur, onLinkPress: onEscapeKey }) })) : null })] }));
259
273
  });
260
- export const TabView = ({ tabs, otherLabel, hideIfSingleItem = true, sticky = false, padding, }) => {
274
+ const isTabActive = ({ tab, activeTabId, currentLocationURL }) => match(tab)
275
+ .with({ url: P.string }, ({ url }) => currentLocationURL.startsWith(url))
276
+ .with({ id: P.string }, ({ id }) => isNotNullish(activeTabId) && id === activeTabId)
277
+ .exhaustive();
278
+ const getTabId = (tab) => match(tab)
279
+ .with({ url: P.string }, ({ url }) => url)
280
+ .with({ id: P.string }, ({ id }) => id)
281
+ .exhaustive();
282
+ export const TabView = ({ tabs, otherLabel, hideIfSingleItem = true, sticky = false, padding, activeTabId, onChange, }) => {
261
283
  const containerRef = useRef(null);
262
284
  const placeholderRef = useRef(null);
263
285
  const otherPlaceholderRef = useRef(null);
@@ -273,8 +295,10 @@ export const TabView = ({ tabs, otherLabel, hideIfSingleItem = true, sticky = fa
273
295
  if (isNotNullish(linksRefs.current)) {
274
296
  const values = Object.entries(linksRefs.current);
275
297
  const container = containerRef.current;
276
- for (const [link, node] of values) {
277
- if ("/" + path.join("/") === link && isNotNullish(node) && isNotNullish(container)) {
298
+ for (const [tabId, node] of values) {
299
+ if ((tabId === activeTabId || "/" + path.join("/") === tabId) &&
300
+ isNotNullish(node) &&
301
+ isNotNullish(container)) {
278
302
  node.measureLayout(container, (left, _, width) => {
279
303
  const leftOffset = padding ?? 0;
280
304
  setUnderlinePosition({ left: left - leftOffset, width });
@@ -284,14 +308,15 @@ export const TabView = ({ tabs, otherLabel, hideIfSingleItem = true, sticky = fa
284
308
  }
285
309
  }
286
310
  setUnderlinePosition({ left: 0, width: 0 });
287
- }, [path, kept, collapsed, padding]);
311
+ }, [path, kept, collapsed, padding, activeTabId]);
288
312
  useEffect(() => {
289
313
  setHasRendered(width > 0);
290
314
  }, [width]);
291
315
  const reajustLayout = useCallback(({ width }) => {
292
316
  const items = tabs.map(tab => {
293
317
  if (placeholderLinkRef.current) {
294
- const ref = placeholderLinkRef.current[tab.url];
318
+ const tabId = getTabId(tab);
319
+ const ref = placeholderLinkRef.current[tabId];
295
320
  if (isNotNullish(ref)) {
296
321
  const element = ref;
297
322
  const width = element.getBoundingClientRect().width;
@@ -314,7 +339,7 @@ export const TabView = ({ tabs, otherLabel, hideIfSingleItem = true, sticky = fa
314
339
  kept.push({ ...item.tab, width: item.width });
315
340
  }
316
341
  else {
317
- if (currentLocationURL.startsWith(item.tab.url)) {
342
+ if (isTabActive({ activeTabId, currentLocationURL, tab: item.tab })) {
318
343
  while (kept.length !== 0 &&
319
344
  kept.reduce((acc, item) => acc + item.width, 0) + (item.width + 16) >= width) {
320
345
  const last = kept.pop();
@@ -336,7 +361,7 @@ export const TabView = ({ tabs, otherLabel, hideIfSingleItem = true, sticky = fa
336
361
  const otherLabel = otherLabelRef;
337
362
  otherLabelWidth = otherLabel.getBoundingClientRect().width;
338
363
  }
339
- const activeInKeptIndex = kept.findIndex(item => currentLocationURL.startsWith(item.url));
364
+ const activeInKeptIndex = kept.findIndex(item => isTabActive({ activeTabId, currentLocationURL, tab: item }));
340
365
  if (activeInKeptIndex !== -1) {
341
366
  const activeInKept = kept[activeInKeptIndex];
342
367
  const activeInKeptWidth = activeInKept?.width ?? 0;
@@ -364,7 +389,7 @@ export const TabView = ({ tabs, otherLabel, hideIfSingleItem = true, sticky = fa
364
389
  else {
365
390
  setKeptCollapsed([kept, collapsed]);
366
391
  }
367
- }, [tabs, currentLocationURL]);
392
+ }, [tabs, activeTabId, currentLocationURL]);
368
393
  const onLayout = useCallback(({ target, nativeEvent: { layout: { width }, }, }) => {
369
394
  reajustLayout({ container: target, width });
370
395
  }, [reajustLayout]);
@@ -379,27 +404,36 @@ export const TabView = ({ tabs, otherLabel, hideIfSingleItem = true, sticky = fa
379
404
  if (tabs.length <= 1 && hideIfSingleItem) {
380
405
  return null;
381
406
  }
382
- return (_jsxs(Box, { alignItems: "center", direction: "row", role: "tablist", ref: containerRef, style: [styles.container, sticky && styles.sticky, { paddingHorizontal: padding }], children: [_jsxs(View, { style: styles.placeholder, "aria-hidden": true, ref: placeholderRef, onLayout: onLayout, children: [tabs.map(({ label, url, icon, count }) => (_jsxs(Fragment, { children: [_jsxs(Link, { ref: ref => {
383
- if (placeholderLinkRef.current) {
384
- placeholderLinkRef.current[url] = ref;
385
- }
386
- }, to: url, style: ({ active, hovered }) => [
387
- styles.link,
388
- active ? styles.activeLink : hovered ? styles.hoveredLink : null,
389
- ], children: [isNotNullish(icon) && (_jsxs(_Fragment, { children: [_jsx(Icon, { name: icon, size: 16, color: "currentColor" }), _jsx(Space, { width: 8 })] })), _jsx(Text, { children: label }), count != null ? (_jsxs(_Fragment, { children: [_jsx(Space, { width: 8 }), _jsx(Text, { style: styles.count, children: count })] })) : null] }), _jsx(Space, { width: 32 })] }, url))), _jsxs(LakeText, { ref: otherPlaceholderRef, style: styles.link, children: [_jsx(Text, { children: otherLabel }), _jsx(Space, { width: 8 }), _jsx(Text, { style: styles.count, children: tabs.length }), _jsx(Space, { width: 4 }), _jsx(Icon, { name: "chevron-down-filled", size: 12 })] })] }), kept.map(({ label, url, icon, withSeparator, count }) => (_jsxs(Fragment, { children: [_jsxs(Link, { role: "tab", ref: ref => {
390
- if (linksRefs.current) {
391
- linksRefs.current[url] = ref;
392
- }
393
- }, to: url, style: ({ active, hovered }) => [
394
- styles.link,
395
- active ? styles.activeLink : hovered ? styles.hoveredLink : null,
396
- ], children: [withSeparator === true && _jsx(View, { style: styles.separator, role: "none" }), isNotNullish(icon) && (_jsxs(_Fragment, { children: [_jsx(Icon, { name: icon, size: 16, color: "currentColor" }), _jsx(Space, { width: 8 })] })), _jsx(Text, { children: label }), count != null ? (_jsxs(_Fragment, { children: [_jsx(Space, { width: 8 }), _jsx(Text, { style: styles.count, children: count })] })) : null] }), _jsx(Space, { width: 32 })] }, url))), collapsed.length > 0 ? (_jsx(DropdownItems, { ref: ref => {
407
+ return (_jsxs(Box, { alignItems: "center", direction: "row", role: "tablist", ref: containerRef, style: [styles.container, sticky && styles.sticky, { paddingHorizontal: padding }], children: [_jsxs(View, { style: styles.placeholder, "aria-hidden": true, ref: placeholderRef, onLayout: onLayout, children: [tabs.map(tab => {
408
+ const { label, icon, count } = tab;
409
+ const tabId = getTabId(tab);
410
+ return (_jsxs(Fragment, { children: [_jsxs(TabViewLink, { ref: ref => {
411
+ if (placeholderLinkRef.current) {
412
+ placeholderLinkRef.current[tabId] = ref;
413
+ }
414
+ }, activeTabId: activeTabId, tab: tab, onChange: onChange, style: ({ active, hovered }) => [
415
+ styles.link,
416
+ active ? styles.activeLink : hovered ? styles.hoveredLink : null,
417
+ ], children: [isNotNullish(icon) && (_jsxs(_Fragment, { children: [_jsx(Icon, { name: icon, size: 16, color: "currentColor" }), _jsx(Space, { width: 8 })] })), _jsx(Text, { children: label }), count != null ? (_jsxs(_Fragment, { children: [_jsx(Space, { width: 8 }), _jsx(Text, { style: styles.count, children: count })] })) : null] }), _jsx(Space, { width: 32 })] }, tabId));
418
+ }), _jsxs(LakeText, { ref: otherPlaceholderRef, style: styles.link, children: [_jsx(Text, { children: otherLabel }), _jsx(Space, { width: 8 }), _jsx(Text, { style: styles.count, children: tabs.length }), _jsx(Space, { width: 4 }), _jsx(Icon, { name: "chevron-down-filled", size: 12 })] })] }), kept.map(tab => {
419
+ const { label, icon, withSeparator, count } = tab;
420
+ const tabId = getTabId(tab);
421
+ return (_jsxs(Fragment, { children: [_jsxs(TabViewLink, { ref: ref => {
422
+ if (linksRefs.current) {
423
+ linksRefs.current[tabId] = ref;
424
+ }
425
+ }, onChange: onChange, activeTabId: activeTabId, tab: tab, role: "tab", style: ({ active, hovered }) => [
426
+ styles.link,
427
+ active ? styles.activeLink : hovered ? styles.hoveredLink : null,
428
+ ], children: [withSeparator === true && _jsx(View, { style: styles.separator, role: "none" }), isNotNullish(icon) && (_jsxs(_Fragment, { children: [_jsx(Icon, { name: icon, size: 16, color: "currentColor" }), _jsx(Space, { width: 8 })] })), _jsx(Text, { children: label }), count != null ? (_jsxs(_Fragment, { children: [_jsx(Space, { width: 8 }), _jsx(Text, { style: styles.count, children: count })] })) : null] }), _jsx(Space, { width: 32 })] }, tabId));
429
+ }), collapsed.length > 0 ? (_jsx(DropdownItems, { ref: ref => {
397
430
  collapsed.forEach(item => {
398
431
  if (linksRefs.current) {
399
- linksRefs.current[item.url] = ref;
432
+ const tabId = getTabId(item);
433
+ linksRefs.current[tabId] = ref;
400
434
  }
401
435
  });
402
- }, tabs: collapsed, currentUrl: currentLocationURL, otherLabel: otherLabel })) : null, hasRendered && (_jsx(View, { style: [
436
+ }, onChange: onChange, tabs: collapsed, currentUrl: currentLocationURL, otherLabel: otherLabel, activeTabId: activeTabId })) : null, hasRendered && (_jsx(View, { style: [
403
437
  styles.underline,
404
438
  styles.animatedUnderline,
405
439
  { transform: `translateX(${left}px) scaleX(${width})` },