@ankhorage/surface 1.0.3 → 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 (77) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +17 -1
  3. package/dist/components/navigation/DrawerNavigation.d.ts +4 -0
  4. package/dist/components/navigation/DrawerNavigation.d.ts.map +1 -0
  5. package/dist/components/navigation/DrawerNavigation.js +13 -0
  6. package/dist/components/navigation/DrawerNavigation.js.map +1 -0
  7. package/dist/components/navigation/DrawerNavigationItem.d.ts +4 -0
  8. package/dist/components/navigation/DrawerNavigationItem.d.ts.map +1 -0
  9. package/dist/components/navigation/DrawerNavigationItem.js +9 -0
  10. package/dist/components/navigation/DrawerNavigationItem.js.map +1 -0
  11. package/dist/components/navigation/NavigationItem.d.ts +4 -0
  12. package/dist/components/navigation/NavigationItem.d.ts.map +1 -0
  13. package/dist/components/navigation/NavigationItem.js +41 -0
  14. package/dist/components/navigation/NavigationItem.js.map +1 -0
  15. package/dist/components/navigation/NavigationList.d.ts +4 -0
  16. package/dist/components/navigation/NavigationList.d.ts.map +1 -0
  17. package/dist/components/navigation/NavigationList.js +9 -0
  18. package/dist/components/navigation/NavigationList.js.map +1 -0
  19. package/dist/components/navigation/TabBar.d.ts +4 -0
  20. package/dist/components/navigation/TabBar.d.ts.map +1 -0
  21. package/dist/components/navigation/TabBar.js +15 -0
  22. package/dist/components/navigation/TabBar.js.map +1 -0
  23. package/dist/components/navigation/TabBarItem.d.ts +4 -0
  24. package/dist/components/navigation/TabBarItem.d.ts.map +1 -0
  25. package/dist/components/navigation/TabBarItem.js +44 -0
  26. package/dist/components/navigation/TabBarItem.js.map +1 -0
  27. package/dist/components/navigation/helpers.d.ts +15 -0
  28. package/dist/components/navigation/helpers.d.ts.map +1 -0
  29. package/dist/components/navigation/helpers.js +40 -0
  30. package/dist/components/navigation/helpers.js.map +1 -0
  31. package/dist/components/navigation/index.d.ts +8 -0
  32. package/dist/components/navigation/index.d.ts.map +1 -0
  33. package/dist/components/navigation/index.js +7 -0
  34. package/dist/components/navigation/index.js.map +1 -0
  35. package/dist/components/navigation/types.d.ts +51 -0
  36. package/dist/components/navigation/types.d.ts.map +1 -0
  37. package/dist/components/navigation/types.js +2 -0
  38. package/dist/components/navigation/types.js.map +1 -0
  39. package/dist/index.d.ts +4 -0
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.js +2 -0
  42. package/dist/index.js.map +1 -1
  43. package/dist/primitives/image/Image.d.ts +4 -0
  44. package/dist/primitives/image/Image.d.ts.map +1 -0
  45. package/dist/primitives/image/Image.js +28 -0
  46. package/dist/primitives/image/Image.js.map +1 -0
  47. package/dist/primitives/image/index.d.ts +3 -0
  48. package/dist/primitives/image/index.d.ts.map +1 -0
  49. package/dist/primitives/image/index.js +2 -0
  50. package/dist/primitives/image/index.js.map +1 -0
  51. package/dist/primitives/image/resolveImageProps.d.ts +12 -0
  52. package/dist/primitives/image/resolveImageProps.d.ts.map +1 -0
  53. package/dist/primitives/image/resolveImageProps.js +67 -0
  54. package/dist/primitives/image/resolveImageProps.js.map +1 -0
  55. package/dist/primitives/image/types.d.ts +21 -0
  56. package/dist/primitives/image/types.d.ts.map +1 -0
  57. package/dist/primitives/image/types.js +2 -0
  58. package/dist/primitives/image/types.js.map +1 -0
  59. package/package.json +2 -2
  60. package/src/components/navigation/DrawerNavigation.tsx +25 -0
  61. package/src/components/navigation/DrawerNavigationItem.tsx +17 -0
  62. package/src/components/navigation/NavigationItem.tsx +77 -0
  63. package/src/components/navigation/NavigationList.tsx +24 -0
  64. package/src/components/navigation/TabBar.tsx +25 -0
  65. package/src/components/navigation/TabBarItem.tsx +82 -0
  66. package/src/components/navigation/helpers.test.ts +74 -0
  67. package/src/components/navigation/helpers.ts +72 -0
  68. package/src/components/navigation/index.ts +16 -0
  69. package/src/components/navigation/policy.test.ts +44 -0
  70. package/src/components/navigation/types.ts +59 -0
  71. package/src/index.test.ts +2 -0
  72. package/src/index.ts +20 -0
  73. package/src/primitives/image/Image.tsx +68 -0
  74. package/src/primitives/image/index.ts +2 -0
  75. package/src/primitives/image/resolveImageProps.test.ts +81 -0
  76. package/src/primitives/image/resolveImageProps.ts +92 -0
  77. package/src/primitives/image/types.ts +28 -0
@@ -0,0 +1,59 @@
1
+ import type React from 'react';
2
+ import type { AccessibilityRole, AccessibilityState } from 'react-native';
3
+
4
+ import type { IconProps } from '../../primitives/icon';
5
+
6
+ export type NavigationItemIcon = Pick<IconProps, 'name' | 'provider'>;
7
+
8
+ export interface NavigationItemSpec {
9
+ id: string;
10
+ label: React.ReactNode;
11
+ icon?: NavigationItemIcon;
12
+ badge?: React.ReactNode;
13
+ active?: boolean;
14
+ disabled?: boolean;
15
+ onPress?: (() => void) | undefined;
16
+ accessibilityLabel?: string;
17
+ accessibilityRole?: AccessibilityRole;
18
+ accessibilityState?: AccessibilityState;
19
+ testID?: string;
20
+ }
21
+
22
+ export interface NavigationItemProps {
23
+ item: NavigationItemSpec;
24
+ compact?: boolean;
25
+ testID?: string;
26
+ }
27
+
28
+ export interface NavigationListProps {
29
+ items: readonly NavigationItemSpec[];
30
+ orientation?: 'vertical' | 'horizontal';
31
+ compact?: boolean;
32
+ testID?: string;
33
+ }
34
+
35
+ export interface TabBarProps {
36
+ items: readonly NavigationItemSpec[];
37
+ compact?: boolean;
38
+ testID?: string;
39
+ }
40
+
41
+ export interface TabBarItemProps {
42
+ item: NavigationItemSpec;
43
+ compact?: boolean;
44
+ testID?: string;
45
+ }
46
+
47
+ export interface DrawerNavigationProps {
48
+ items: readonly NavigationItemSpec[];
49
+ header?: React.ReactNode;
50
+ footer?: React.ReactNode;
51
+ compact?: boolean;
52
+ testID?: string;
53
+ }
54
+
55
+ export interface DrawerNavigationItemProps {
56
+ item: NavigationItemSpec;
57
+ compact?: boolean;
58
+ testID?: string;
59
+ }
package/src/index.test.ts CHANGED
@@ -21,6 +21,7 @@ const expectedRootExports = [
21
21
  "export { IconButton } from './components/icon-button';",
22
22
  "export { Label } from './components/label';",
23
23
  "export { ListItem } from './components/list-item';",
24
+ "export {\n DrawerNavigation,\n DrawerNavigationItem,\n NavigationItem,\n NavigationList,\n TabBar,\n TabBarItem,\n} from './components/navigation';",
24
25
  "export { Menu } from './components/menu';",
25
26
  "export { Modal } from './components/modal';",
26
27
  "export { Radio } from './components/radio';",
@@ -32,6 +33,7 @@ const expectedRootExports = [
32
33
  "export { Tooltip } from './components/tooltip';",
33
34
  "export * from './core/responsive';",
34
35
  "export * from './layout';",
36
+ "export { Image } from './primitives/image';",
35
37
  "export * from './theme';",
36
38
  ] as const;
37
39
 
package/src/index.ts CHANGED
@@ -22,6 +22,24 @@ export type { MenuItem, MenuProps } from './components/menu';
22
22
  export { Menu } from './components/menu';
23
23
  export type { ModalProps } from './components/modal';
24
24
  export { Modal } from './components/modal';
25
+ export type {
26
+ DrawerNavigationItemProps,
27
+ DrawerNavigationProps,
28
+ NavigationItemIcon,
29
+ NavigationItemProps,
30
+ NavigationItemSpec,
31
+ NavigationListProps,
32
+ TabBarItemProps,
33
+ TabBarProps,
34
+ } from './components/navigation';
35
+ export {
36
+ DrawerNavigation,
37
+ DrawerNavigationItem,
38
+ NavigationItem,
39
+ NavigationList,
40
+ TabBar,
41
+ TabBarItem,
42
+ } from './components/navigation';
25
43
  export type { RadioProps } from './components/radio';
26
44
  export { Radio } from './components/radio';
27
45
  export type { SwitchProps } from './components/switch';
@@ -48,6 +66,8 @@ export type { HeadingLevel, HeadingProps } from './primitives/heading';
48
66
  export { Heading } from './primitives/heading';
49
67
  export type { IconProps, IconProvider } from './primitives/icon';
50
68
  export { Icon } from './primitives/icon';
69
+ export type { ImageFit, ImageProps, SurfaceImageSource } from './primitives/image';
70
+ export { Image } from './primitives/image';
51
71
  export type { TextProps } from './primitives/text';
52
72
  export { Text } from './primitives/text';
53
73
  export * from './theme';
@@ -0,0 +1,68 @@
1
+ import React from 'react';
2
+ import { Image as RNImage } from 'react-native';
3
+
4
+ import { useTheme } from '../../theme/ThemeContext';
5
+ import {
6
+ getSourceKey,
7
+ normalizeSource,
8
+ resolveAccessibilityLabel,
9
+ resolveAlt,
10
+ resolveImageStyle,
11
+ resolveRenderedSource,
12
+ resolveResizeMode,
13
+ } from './resolveImageProps';
14
+ import type { ImageProps } from './types';
15
+
16
+ export function Image({
17
+ source,
18
+ fallbackSource,
19
+ alt,
20
+ accessibilityLabel,
21
+ width,
22
+ height,
23
+ aspectRatio,
24
+ fit,
25
+ resizeMode,
26
+ radius,
27
+ style,
28
+ testID,
29
+ onError,
30
+ }: ImageProps) {
31
+ const { theme } = useTheme();
32
+ const [showFallback, setShowFallback] = React.useState(false);
33
+ const primaryKey = React.useMemo(() => getSourceKey(source), [source]);
34
+
35
+ React.useEffect(() => {
36
+ setShowFallback(false);
37
+ }, [primaryKey]);
38
+
39
+ const normalizedPrimary = normalizeSource(source);
40
+ const normalizedFallback = normalizeSource(fallbackSource);
41
+ const resolvedSource = resolveRenderedSource(normalizedPrimary, normalizedFallback, showFallback);
42
+
43
+ if (!resolvedSource) return null;
44
+
45
+ const resolvedResizeMode = resolveResizeMode(fit, resizeMode);
46
+ const resolvedAccessibilityLabel = resolveAccessibilityLabel(alt, accessibilityLabel);
47
+ const resolvedAlt = resolveAlt(alt, accessibilityLabel);
48
+ const resolvedStyle = resolveImageStyle(theme, { width, height, aspectRatio, radius });
49
+
50
+ return (
51
+ <RNImage
52
+ accessibilityLabel={resolvedAccessibilityLabel}
53
+ accessibilityRole="image"
54
+ alt={resolvedAlt}
55
+ onError={(event) => {
56
+ onError?.(event);
57
+
58
+ if (!showFallback && normalizedFallback) {
59
+ setShowFallback(true);
60
+ }
61
+ }}
62
+ resizeMode={resolvedResizeMode}
63
+ source={resolvedSource}
64
+ style={[resolvedStyle, style]}
65
+ testID={testID}
66
+ />
67
+ );
68
+ }
@@ -0,0 +1,2 @@
1
+ export { Image } from './Image';
2
+ export type { ImageFit, ImageProps, SurfaceImageSource } from './types';
@@ -0,0 +1,81 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+
3
+ import { createTheme } from '../../theme/createTheme';
4
+ import {
5
+ getSourceKey,
6
+ normalizeSource,
7
+ resolveImageStyle,
8
+ resolveRenderedSource,
9
+ resolveResizeMode,
10
+ } from './resolveImageProps';
11
+
12
+ describe('normalizeSource', () => {
13
+ it('normalizes string sources into uri objects', () => {
14
+ expect(normalizeSource('https://example.com/image.png')).toEqual({
15
+ uri: 'https://example.com/image.png',
16
+ });
17
+ });
18
+
19
+ it('passes through react-native image sources', () => {
20
+ expect(normalizeSource({ uri: 'https://example.com/image.png' })).toEqual({
21
+ uri: 'https://example.com/image.png',
22
+ });
23
+ });
24
+
25
+ it('returns undefined for nullish sources', () => {
26
+ expect(normalizeSource(null)).toBeUndefined();
27
+ expect(normalizeSource(undefined)).toBeUndefined();
28
+ });
29
+ });
30
+
31
+ describe('resolveResizeMode', () => {
32
+ it('prefers fit over resizeMode', () => {
33
+ expect(resolveResizeMode('contain', 'cover')).toBe('contain');
34
+ });
35
+
36
+ it('defaults to cover when both are absent', () => {
37
+ expect(resolveResizeMode(undefined, undefined)).toBe('cover');
38
+ });
39
+ });
40
+
41
+ describe('resolveRenderedSource', () => {
42
+ it('renders fallback when the primary source is absent', () => {
43
+ const normalizedFallback = normalizeSource('https://example.com/fallback.png');
44
+ expect(resolveRenderedSource(undefined, normalizedFallback, false)).toEqual(normalizedFallback);
45
+ });
46
+ });
47
+
48
+ describe('getSourceKey', () => {
49
+ it('derives stable keys for string and uri sources', () => {
50
+ expect(getSourceKey('https://example.com/image.png')).toBe('uri:https://example.com/image.png');
51
+ expect(getSourceKey({ uri: 'https://example.com/image.png' })).toBe(
52
+ 'uri:https://example.com/image.png',
53
+ );
54
+ });
55
+
56
+ it('derives stable keys for asset sources', () => {
57
+ expect(getSourceKey(12)).toBe('asset:12');
58
+ });
59
+
60
+ it('derives stable keys for uri sets', () => {
61
+ expect(getSourceKey([{ uri: 'a' }, { uri: 'b' }])).toBe('uri-set:a|b');
62
+ });
63
+ });
64
+
65
+ describe('resolveImageStyle', () => {
66
+ it('resolves spacing tokens for width/height and preserves raw strings', () => {
67
+ const theme = createTheme();
68
+ const style = resolveImageStyle(theme, { width: 'm', height: '100%' });
69
+
70
+ expect(style.width).toBe(theme.spacing.m);
71
+ expect(style.height).toBe('100%');
72
+ });
73
+
74
+ it('resolves radius tokens for borderRadius', () => {
75
+ const theme = createTheme();
76
+ const style = resolveImageStyle(theme, { radius: 'full' });
77
+
78
+ expect(style.borderRadius).toBe(theme.radii.full);
79
+ expect(style.overflow).toBe('hidden');
80
+ });
81
+ });
@@ -0,0 +1,92 @@
1
+ import type { ImageResizeMode, ImageSourcePropType } from 'react-native';
2
+ import type { ImageStyle } from 'react-native';
3
+
4
+ import { resolveToken } from '../../theme/resolveToken';
5
+ import type { SurfaceTheme } from '../../theme/types';
6
+ import type { ImageProps, SurfaceImageSource } from './types';
7
+
8
+ export function normalizeSource(
9
+ source: SurfaceImageSource | null | undefined,
10
+ ): ImageSourcePropType | undefined {
11
+ if (source === null || source === undefined) return undefined;
12
+ if (typeof source === 'string') return { uri: source };
13
+ return source;
14
+ }
15
+
16
+ export function resolveRenderedSource(
17
+ normalizedPrimary: ImageSourcePropType | undefined,
18
+ normalizedFallback: ImageSourcePropType | undefined,
19
+ showFallback: boolean,
20
+ ): ImageSourcePropType | undefined {
21
+ if (showFallback || !normalizedPrimary) return normalizedFallback;
22
+ return normalizedPrimary;
23
+ }
24
+
25
+ function getUriFromSource(source: ImageSourcePropType): string | undefined {
26
+ if (Array.isArray(source)) {
27
+ const [first] = source;
28
+ return first?.uri;
29
+ }
30
+
31
+ if (typeof source === 'number') return undefined;
32
+ return source.uri;
33
+ }
34
+
35
+ export function getSourceKey(source: SurfaceImageSource | null | undefined): string | null {
36
+ if (source === null || source === undefined) return null;
37
+ if (typeof source === 'string') return `uri:${source}`;
38
+ if (typeof source === 'number') return `asset:${source}`;
39
+ if (Array.isArray(source)) {
40
+ const parts = source
41
+ .map((item) => item.uri)
42
+ .filter((value): value is string => typeof value === 'string');
43
+ return `uri-set:${parts.join('|')}`;
44
+ }
45
+
46
+ const uri = getUriFromSource(source);
47
+ if (uri) return `uri:${uri}`;
48
+
49
+ try {
50
+ return `src:${JSON.stringify(source)}`;
51
+ } catch {
52
+ return 'src:unknown';
53
+ }
54
+ }
55
+
56
+ export function resolveResizeMode(
57
+ fit: ImageProps['fit'],
58
+ resizeMode: ImageProps['resizeMode'],
59
+ ): ImageResizeMode {
60
+ return fit ?? resizeMode ?? 'cover';
61
+ }
62
+
63
+ export function resolveAccessibilityLabel(
64
+ alt: ImageProps['alt'],
65
+ accessibilityLabel: ImageProps['accessibilityLabel'],
66
+ ): string | undefined {
67
+ return accessibilityLabel ?? alt;
68
+ }
69
+
70
+ export function resolveAlt(
71
+ alt: ImageProps['alt'],
72
+ accessibilityLabel: ImageProps['accessibilityLabel'],
73
+ ): string | undefined {
74
+ return alt ?? accessibilityLabel;
75
+ }
76
+
77
+ export function resolveImageStyle(
78
+ theme: SurfaceTheme,
79
+ props: Pick<ImageProps, 'width' | 'height' | 'aspectRatio' | 'radius'>,
80
+ ): ImageStyle {
81
+ const width = resolveToken(theme.spacing, props.width);
82
+ const height = resolveToken(theme.spacing, props.height);
83
+ const borderRadius = resolveToken(theme.radii, props.radius);
84
+
85
+ return {
86
+ width,
87
+ height,
88
+ aspectRatio: props.aspectRatio,
89
+ borderRadius,
90
+ overflow: borderRadius !== undefined ? 'hidden' : undefined,
91
+ };
92
+ }
@@ -0,0 +1,28 @@
1
+ import type {
2
+ ImageProps as ReactNativeImageProps,
3
+ ImageResizeMode,
4
+ ImageSourcePropType,
5
+ } from 'react-native';
6
+ import type { ImageStyle, StyleProp } from 'react-native';
7
+
8
+ import type { SurfaceTheme } from '../../theme/types';
9
+
10
+ export type SurfaceImageSource = string | ImageSourcePropType;
11
+
12
+ export type ImageFit = ImageResizeMode;
13
+
14
+ export interface ImageProps {
15
+ source?: SurfaceImageSource | null;
16
+ fallbackSource?: SurfaceImageSource | null;
17
+ alt?: string;
18
+ accessibilityLabel?: string;
19
+ width?: number | string;
20
+ height?: number | string;
21
+ aspectRatio?: number;
22
+ fit?: ImageFit;
23
+ resizeMode?: ImageResizeMode;
24
+ radius?: number | keyof SurfaceTheme['radii'];
25
+ style?: StyleProp<ImageStyle>;
26
+ testID?: string;
27
+ onError?: ReactNativeImageProps['onError'];
28
+ }