@pallasoft/ps-mobile-components 1.0.0 → 1.2.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.
Files changed (32) hide show
  1. package/dist/components/ps-count-badge/index.d.ts +2 -0
  2. package/dist/components/ps-count-badge/index.js +2 -0
  3. package/dist/components/ps-count-badge/ps-count-badge-props.d.ts +5 -0
  4. package/dist/components/ps-count-badge/ps-count-badge-props.js +1 -0
  5. package/dist/components/ps-count-badge/ps-count-badge.d.ts +2 -0
  6. package/dist/components/ps-count-badge/ps-count-badge.js +25 -0
  7. package/dist/components/ps-list/index.d.ts +2 -0
  8. package/dist/components/ps-list/index.js +2 -0
  9. package/dist/components/ps-list/ps-list-link-row-props.d.ts +11 -0
  10. package/dist/components/ps-list/ps-list-link-row-props.js +1 -0
  11. package/dist/components/ps-list/ps-list-link-row-skeleton.d.ts +1 -0
  12. package/dist/components/ps-list/ps-list-link-row-skeleton.js +14 -0
  13. package/dist/components/ps-list/ps-list-link-row.d.ts +2 -0
  14. package/dist/components/ps-list/ps-list-link-row.js +40 -0
  15. package/dist/constants/ps-count-badge-config.d.ts +6 -0
  16. package/dist/constants/ps-count-badge-config.js +6 -0
  17. package/dist/constants/ps-layout-config.d.ts +4 -0
  18. package/dist/constants/ps-layout-config.js +4 -0
  19. package/dist/hooks/use-ps-remote-data.d.ts +1 -1
  20. package/dist/hooks/use-ps-remote-data.js +58 -28
  21. package/dist/index.d.ts +1 -0
  22. package/dist/index.js +1 -0
  23. package/dist/interfaces/ps-remote-data-options.d.ts +1 -0
  24. package/dist/interfaces/ps-remote-data-update.d.ts +1 -0
  25. package/dist/interfaces/ps-remote-data-update.js +1 -0
  26. package/dist/interfaces/ps-remote-data-write.d.ts +4 -0
  27. package/dist/interfaces/ps-remote-data-write.js +1 -0
  28. package/dist/services/ps-count-badge-service.d.ts +3 -0
  29. package/dist/services/ps-count-badge-service.js +6 -0
  30. package/dist/services/ps-remote-data-service.d.ts +6 -0
  31. package/dist/services/ps-remote-data-service.js +13 -0
  32. package/package.json +2 -2
@@ -0,0 +1,2 @@
1
+ export * from './ps-count-badge';
2
+ export * from './ps-count-badge-props';
@@ -0,0 +1,2 @@
1
+ export * from './ps-count-badge';
2
+ export * from './ps-count-badge-props';
@@ -0,0 +1,5 @@
1
+ export interface PsCountBadgeProps {
2
+ count: number;
3
+ isAlert?: boolean;
4
+ label?: string;
5
+ }
@@ -0,0 +1,2 @@
1
+ import type { PsCountBadgeProps } from './ps-count-badge-props';
2
+ export declare const PsCountBadge: ({ count, isAlert, label }: PsCountBadgeProps) => import("react").JSX.Element | null;
@@ -0,0 +1,25 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { View } from 'react-native';
3
+ import { PsCountBadgeConfig } from '../../constants/ps-count-badge-config';
4
+ import { usePsMobileTheme } from '../../hooks/use-ps-mobile-theme';
5
+ import { PsCountBadgeService } from '../../services/ps-count-badge-service';
6
+ import { PsText } from '../ps-text/ps-text';
7
+ export const PsCountBadge = ({ count, isAlert = false, label }) => {
8
+ const theme = usePsMobileTheme();
9
+ if (count <= 0)
10
+ return null;
11
+ return (_jsx(View, { accessibilityLabel: label, style: {
12
+ alignItems: 'center',
13
+ alignSelf: 'center',
14
+ backgroundColor: isAlert ? theme.danger : theme.primary,
15
+ borderRadius: PsCountBadgeConfig.height / 2,
16
+ height: PsCountBadgeConfig.height,
17
+ justifyContent: 'center',
18
+ minWidth: PsCountBadgeConfig.height,
19
+ paddingHorizontal: PsCountBadgeConfig.paddingHorizontal,
20
+ }, testID: 'ps-count-badge', children: _jsx(PsText, { style: {
21
+ color: isAlert ? theme.white : theme.primaryContrast,
22
+ fontSize: PsCountBadgeConfig.fontSize,
23
+ lineHeight: PsCountBadgeConfig.height,
24
+ }, weight: 'bold', children: PsCountBadgeService.text(count) }) }));
25
+ };
@@ -8,3 +8,5 @@ export * from './ps-list-select-row';
8
8
  export * from './ps-list-select-row-props';
9
9
  export * from './ps-list-person-row';
10
10
  export * from './ps-list-person-row-props';
11
+ export * from './ps-list-link-row';
12
+ export * from './ps-list-link-row-props';
@@ -8,3 +8,5 @@ export * from './ps-list-select-row';
8
8
  export * from './ps-list-select-row-props';
9
9
  export * from './ps-list-person-row';
10
10
  export * from './ps-list-person-row-props';
11
+ export * from './ps-list-link-row';
12
+ export * from './ps-list-link-row-props';
@@ -0,0 +1,11 @@
1
+ import type { PsMobileIconKey } from '../../constants/ps-mobile-icon-key';
2
+ export interface PsListLinkRowProps {
3
+ count?: number;
4
+ countLabel?: string;
5
+ iconKey: PsMobileIconKey;
6
+ isAlert?: boolean;
7
+ isLoading?: boolean;
8
+ onPress: () => void;
9
+ subtitle?: string;
10
+ title: string;
11
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export declare const PsListLinkRowSkeleton: () => import("react").JSX.Element;
@@ -0,0 +1,14 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { View } from 'react-native';
3
+ import { PsLayoutConfig } from '../../constants/ps-layout-config';
4
+ import { PsListConfig } from '../../constants/ps-list-config';
5
+ import { PsSkeletonShape } from '../../constants/ps-skeleton-shape';
6
+ import { PsSkeleton } from '../ps-skeleton/ps-skeleton';
7
+ export const PsListLinkRowSkeleton = () => (_jsxs(View, { style: {
8
+ alignItems: 'center',
9
+ flexDirection: 'row',
10
+ gap: PsLayoutConfig.headerSlotGap,
11
+ minHeight: PsLayoutConfig.linkRowMinHeight,
12
+ paddingHorizontal: PsLayoutConfig.gutter,
13
+ paddingVertical: PsLayoutConfig.rowPaddingVertical,
14
+ }, testID: 'ps-list-link-row-skeleton', children: [_jsx(PsSkeleton, { height: PsLayoutConfig.linkRowIconBox, radius: PsLayoutConfig.linkRowIconBoxRadius, width: PsLayoutConfig.linkRowIconBox }), _jsxs(View, { style: { flex: 1, gap: PsLayoutConfig.rowLineGap * 3 }, children: [_jsx(PsSkeleton, { shape: PsSkeletonShape.Line, width: PsListConfig.skeletonTitleWidth }), _jsx(PsSkeleton, { shape: PsSkeletonShape.Line, width: PsListConfig.skeletonSubtitleWidth })] })] }));
@@ -0,0 +1,2 @@
1
+ import type { PsListLinkRowProps } from './ps-list-link-row-props';
2
+ export declare const PsListLinkRow: ({ count, countLabel, iconKey, isAlert, isLoading, onPress, subtitle, title, }: PsListLinkRowProps) => import("react").JSX.Element;
@@ -0,0 +1,40 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { PsIconKey } from '@pallasoft/ps-icons';
3
+ import { Pressable, View } from 'react-native';
4
+ import { PsLayoutConfig } from '../../constants/ps-layout-config';
5
+ import { PsMobileControlConfig } from '../../constants/ps-mobile-control-config';
6
+ import { usePsHaptics } from '../../hooks/use-ps-haptics';
7
+ import { usePsMobileTheme } from '../../hooks/use-ps-mobile-theme';
8
+ import { PsMobileColorService } from '../../services/ps-mobile-color-service';
9
+ import { PsCountBadge } from '../ps-count-badge/ps-count-badge';
10
+ import { PsIcon } from '../ps-icon/ps-icon';
11
+ import { PsText } from '../ps-text/ps-text';
12
+ import { PsListLinkRowSkeleton } from './ps-list-link-row-skeleton';
13
+ export const PsListLinkRow = ({ count, countLabel, iconKey, isAlert = false, isLoading = false, onPress, subtitle, title, }) => {
14
+ const theme = usePsMobileTheme();
15
+ const haptic = usePsHaptics();
16
+ if (isLoading)
17
+ return _jsx(PsListLinkRowSkeleton, {});
18
+ const hasCount = count !== undefined && count > 0;
19
+ return (_jsxs(Pressable, { accessibilityHint: countLabel, accessibilityRole: 'button', onPress: () => {
20
+ haptic('light');
21
+ onPress();
22
+ }, style: ({ pressed }) => ({
23
+ alignItems: 'center',
24
+ backgroundColor: pressed
25
+ ? PsMobileColorService.withAlpha(theme.ink, PsMobileControlConfig.pressedAlpha)
26
+ : theme.surface,
27
+ flexDirection: 'row',
28
+ gap: PsLayoutConfig.headerSlotGap,
29
+ minHeight: PsLayoutConfig.linkRowMinHeight,
30
+ paddingHorizontal: PsLayoutConfig.gutter,
31
+ paddingVertical: PsLayoutConfig.rowPaddingVertical,
32
+ }), testID: 'ps-list-link-row', children: [_jsx(View, { style: {
33
+ alignItems: 'center',
34
+ backgroundColor: theme.primarySoft,
35
+ borderRadius: PsLayoutConfig.linkRowIconBoxRadius,
36
+ height: PsLayoutConfig.linkRowIconBox,
37
+ justifyContent: 'center',
38
+ width: PsLayoutConfig.linkRowIconBox,
39
+ }, children: _jsx(PsIcon, { color: theme.primary, iconKey: iconKey, size: PsLayoutConfig.linkRowIconSize }) }), _jsxs(View, { style: { flex: 1, gap: PsLayoutConfig.rowLineGap }, children: [_jsx(PsText, { numberOfLines: 1, weight: 'semibold', children: title }), subtitle === undefined ? null : (_jsx(PsText, { numberOfLines: 1, tone: 'muted', variant: 'small', children: subtitle }))] }), hasCount ? _jsx(PsCountBadge, { count: count, isAlert: isAlert, label: countLabel }) : null, _jsx(PsIcon, { color: theme.muted, iconKey: PsIconKey.ChevronRight, size: PsMobileControlConfig.iconSizeInline })] }));
40
+ };
@@ -0,0 +1,6 @@
1
+ export declare const PsCountBadgeConfig: {
2
+ readonly fontSize: 13;
3
+ readonly height: 24;
4
+ readonly maxCount: 99;
5
+ readonly paddingHorizontal: 7;
6
+ };
@@ -0,0 +1,6 @@
1
+ export const PsCountBadgeConfig = {
2
+ fontSize: 13,
3
+ height: 24,
4
+ maxCount: 99,
5
+ paddingHorizontal: 7,
6
+ };
@@ -6,6 +6,10 @@ export declare const PsLayoutConfig: {
6
6
  readonly headerGap: 4;
7
7
  readonly headerSlotGap: 12;
8
8
  readonly hitSlop: 8;
9
+ readonly linkRowIconBox: 44;
10
+ readonly linkRowIconBoxRadius: 12;
11
+ readonly linkRowIconSize: 22;
12
+ readonly linkRowMinHeight: 72;
9
13
  readonly listGap: 24;
10
14
  readonly listSectionGap: 8;
11
15
  readonly longPressDelayMs: 350;
@@ -6,6 +6,10 @@ export const PsLayoutConfig = {
6
6
  headerGap: 4,
7
7
  headerSlotGap: 12,
8
8
  hitSlop: 8,
9
+ linkRowIconBox: 44,
10
+ linkRowIconBoxRadius: 12,
11
+ linkRowIconSize: 22,
12
+ linkRowMinHeight: 72,
9
13
  listGap: 24,
10
14
  listSectionGap: 8,
11
15
  longPressDelayMs: 350,
@@ -1,3 +1,3 @@
1
1
  import { PsRemoteDataOptions } from '../interfaces/ps-remote-data-options';
2
2
  import { PsRemoteDataResult } from '../interfaces/ps-remote-data-result';
3
- export declare const usePsRemoteData: <T>({ key, load, refreshKey, }: PsRemoteDataOptions<T>) => PsRemoteDataResult<T>;
3
+ export declare const usePsRemoteData: <T>({ key, load, onWriteFailed, refreshKey, }: PsRemoteDataOptions<T>) => PsRemoteDataResult<T>;
@@ -1,18 +1,27 @@
1
- import { useCallback, useEffect, useRef, useState } from 'react';
2
- export const usePsRemoteData = ({ key, load, refreshKey, }) => {
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ import { usePsChangeQueue, usePsOptimisticRows } from '@pallasoft/ps-utilities/react';
3
+ import { PsRemoteDataService } from '../services/ps-remote-data-service';
4
+ export const usePsRemoteData = ({ key, load, onWriteFailed, refreshKey, }) => {
3
5
  const [state, setState] = useState({
4
6
  data: null,
5
7
  error: false,
6
8
  isLoading: true,
7
9
  });
10
+ const [isSaving, setIsSaving] = useState(false);
11
+ const isSavingRef = useRef(false);
12
+ const submittedRef = useRef(0);
8
13
  const dataRef = useRef(null);
9
14
  const loadRef = useRef(load);
15
+ const onWriteFailedRef = useRef(onWriteFailed);
10
16
  const versionRef = useRef(0);
11
- const queueRef = useRef(Promise.resolve(true));
12
- const pendingRef = useRef(0);
13
17
  useEffect(() => {
14
18
  loadRef.current = load;
19
+ onWriteFailedRef.current = onWriteFailed;
15
20
  });
21
+ const setSaving = useCallback((value) => {
22
+ isSavingRef.current = value;
23
+ setIsSaving(value);
24
+ }, []);
16
25
  const fetch = useCallback(async () => {
17
26
  versionRef.current += 1;
18
27
  const version = versionRef.current;
@@ -31,42 +40,63 @@ export const usePsRemoteData = ({ key, load, refreshKey, }) => {
31
40
  }, []);
32
41
  useEffect(() => {
33
42
  dataRef.current = null;
34
- pendingRef.current = 0;
43
+ setSaving(false);
35
44
  setState({ data: null, error: false, isLoading: true });
36
45
  void fetch();
37
46
  return () => {
38
47
  versionRef.current += 1;
39
48
  };
40
- }, [key, fetch]);
49
+ }, [key, fetch, setSaving]);
41
50
  const refreshRef = useRef(refreshKey);
42
51
  useEffect(() => {
43
52
  if (refreshRef.current === refreshKey)
44
53
  return;
45
54
  refreshRef.current = refreshKey;
46
- if (pendingRef.current === 0)
55
+ if (!isSavingRef.current)
47
56
  void fetch();
48
57
  }, [fetch, refreshKey]);
58
+ const apply = useCallback((rows, updates) => PsRemoteDataService.apply(rows, updates), []);
59
+ const rows = useMemo(() => PsRemoteDataService.rowsOf(state.data), [state.data]);
60
+ const optimistic = usePsOptimisticRows({ apply, isSaving, rows });
61
+ const rememberRef = useRef(optimistic.remember);
62
+ useEffect(() => {
63
+ rememberRef.current = optimistic.remember;
64
+ });
65
+ const send = useCallback(async (writes) => {
66
+ for (const item of writes) {
67
+ await item.request();
68
+ item.resolve(true);
69
+ }
70
+ }, []);
71
+ const onFailed = useCallback((failed, dropped) => {
72
+ for (const item of [...failed, ...dropped]) {
73
+ optimistic.forgetLast();
74
+ item.resolve(false);
75
+ }
76
+ if (onWriteFailedRef.current !== undefined)
77
+ onWriteFailedRef.current();
78
+ }, [optimistic.forgetLast]);
79
+ const onDrained = useCallback(() => {
80
+ const through = submittedRef.current;
81
+ void fetch().then(() => {
82
+ if (submittedRef.current === through)
83
+ setSaving(false);
84
+ });
85
+ }, [fetch, setSaving]);
86
+ const queue = usePsChangeQueue({ onDrained, onFailed, send });
87
+ const { enqueue } = queue;
49
88
  const write = useCallback((update, request) => {
50
- const previous = dataRef.current;
51
- if (previous === null)
89
+ if (dataRef.current === null)
52
90
  return Promise.resolve(false);
53
- const next = update(previous);
54
- versionRef.current += 1;
55
- dataRef.current = next;
56
- pendingRef.current += 1;
57
- setState({ data: next, error: false, isLoading: false });
58
- const run = queueRef.current.then(() => request().then(() => true, () => false));
59
- queueRef.current = run;
60
- return run.then((isSaved) => {
61
- pendingRef.current -= 1;
62
- if (!isSaved && pendingRef.current === 0) {
63
- dataRef.current = previous;
64
- setState({ data: previous, error: false, isLoading: false });
65
- }
66
- if (pendingRef.current === 0)
67
- void fetch();
68
- return isSaved;
69
- });
70
- }, [fetch]);
71
- return { ...state, reload: fetch, write };
91
+ submittedRef.current += 1;
92
+ setSaving(true);
93
+ rememberRef.current(update);
94
+ return new Promise((resolve) => enqueue({ request, resolve }));
95
+ }, [enqueue, setSaving]);
96
+ return {
97
+ ...state,
98
+ data: PsRemoteDataService.shown(optimistic.rows),
99
+ reload: fetch,
100
+ write,
101
+ };
72
102
  };
package/dist/index.d.ts CHANGED
@@ -25,6 +25,7 @@ export * from './components/ps-mobile-theme-provider';
25
25
  export * from './components/ps-page-backdrop';
26
26
  export * from './components/ps-pallasoft-sign-in-button';
27
27
  export * from './components/ps-color-swatches';
28
+ export * from './components/ps-count-badge';
28
29
  export * from './components/ps-segmented-control';
29
30
  export * from './components/ps-tabs';
30
31
  export * from './components/ps-select';
package/dist/index.js CHANGED
@@ -25,6 +25,7 @@ export * from './components/ps-mobile-theme-provider';
25
25
  export * from './components/ps-page-backdrop';
26
26
  export * from './components/ps-pallasoft-sign-in-button';
27
27
  export * from './components/ps-color-swatches';
28
+ export * from './components/ps-count-badge';
28
29
  export * from './components/ps-segmented-control';
29
30
  export * from './components/ps-tabs';
30
31
  export * from './components/ps-select';
@@ -1,5 +1,6 @@
1
1
  export interface PsRemoteDataOptions<T> {
2
2
  key: string;
3
3
  load: () => Promise<T>;
4
+ onWriteFailed?: () => void;
4
5
  refreshKey?: number | string;
5
6
  }
@@ -0,0 +1 @@
1
+ export type PsRemoteDataUpdate<T> = (data: T) => T;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,4 @@
1
+ export interface PsRemoteDataWrite {
2
+ request: () => Promise<unknown>;
3
+ resolve: (isSaved: boolean) => void;
4
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,3 @@
1
+ export declare class PsCountBadgeService {
2
+ static text(count: number): string;
3
+ }
@@ -0,0 +1,6 @@
1
+ import { PsCountBadgeConfig } from '../constants/ps-count-badge-config';
2
+ export class PsCountBadgeService {
3
+ static text(count) {
4
+ return count > PsCountBadgeConfig.maxCount ? `${PsCountBadgeConfig.maxCount}+` : String(count);
5
+ }
6
+ }
@@ -0,0 +1,6 @@
1
+ import type { PsRemoteDataUpdate } from '../interfaces/ps-remote-data-update';
2
+ export declare class PsRemoteDataService {
3
+ static rowsOf<T>(data: T | null): T[];
4
+ static apply<T>(rows: T[], updates: PsRemoteDataUpdate<T>[]): T[];
5
+ static shown<T>(rows: T[]): T | null;
6
+ }
@@ -0,0 +1,13 @@
1
+ export class PsRemoteDataService {
2
+ static rowsOf(data) {
3
+ return data === null ? [] : [data];
4
+ }
5
+ static apply(rows, updates) {
6
+ if (rows.length === 0)
7
+ return rows;
8
+ return [updates.reduce((current, update) => update(current), rows[0])];
9
+ }
10
+ static shown(rows) {
11
+ return rows.length === 0 ? null : rows[0];
12
+ }
13
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pallasoft/ps-mobile-components",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "private": false,
5
5
  "description": "Native iOS (SwiftUI) and Android (Jetpack Compose) Ps components for Pallasoft mobile apps",
6
6
  "main": "./dist/index.js",
@@ -36,7 +36,7 @@
36
36
  "dependencies": {
37
37
  "@pallasoft/ps-design-system": "^1.17.0",
38
38
  "@pallasoft/ps-icons": "^1.11.0",
39
- "@pallasoft/ps-utilities": "^2.26.0"
39
+ "@pallasoft/ps-utilities": "^2.27.0"
40
40
  },
41
41
  "peerDependencies": {
42
42
  "@expo/ui": "~58.0.0",