@ankhorage/zora 1.0.4 → 1.0.6

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 (154) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +315 -5
  3. package/dist/components/avatar/Avatar.d.ts +4 -0
  4. package/dist/components/avatar/Avatar.d.ts.map +1 -0
  5. package/dist/components/avatar/Avatar.js +80 -0
  6. package/dist/components/avatar/Avatar.js.map +1 -0
  7. package/dist/components/avatar/index.d.ts +4 -0
  8. package/dist/components/avatar/index.d.ts.map +1 -0
  9. package/dist/components/avatar/index.js +3 -0
  10. package/dist/components/avatar/index.js.map +1 -0
  11. package/dist/components/avatar/resolveAvatarInitials.d.ts +2 -0
  12. package/dist/components/avatar/resolveAvatarInitials.d.ts.map +1 -0
  13. package/dist/components/avatar/resolveAvatarInitials.js +44 -0
  14. package/dist/components/avatar/resolveAvatarInitials.js.map +1 -0
  15. package/dist/components/avatar/types.d.ts +17 -0
  16. package/dist/components/avatar/types.d.ts.map +1 -0
  17. package/dist/components/avatar/types.js +2 -0
  18. package/dist/components/avatar/types.js.map +1 -0
  19. package/dist/components/avatar-group/AvatarGroup.d.ts +4 -0
  20. package/dist/components/avatar-group/AvatarGroup.d.ts.map +1 -0
  21. package/dist/components/avatar-group/AvatarGroup.js +26 -0
  22. package/dist/components/avatar-group/AvatarGroup.js.map +1 -0
  23. package/dist/components/avatar-group/index.d.ts +3 -0
  24. package/dist/components/avatar-group/index.d.ts.map +1 -0
  25. package/dist/components/avatar-group/index.js +2 -0
  26. package/dist/components/avatar-group/index.js.map +1 -0
  27. package/dist/components/avatar-group/types.d.ts +22 -0
  28. package/dist/components/avatar-group/types.d.ts.map +1 -0
  29. package/dist/components/avatar-group/types.js +2 -0
  30. package/dist/components/avatar-group/types.js.map +1 -0
  31. package/dist/components/chip/Chip.d.ts +4 -0
  32. package/dist/components/chip/Chip.d.ts.map +1 -0
  33. package/dist/components/chip/Chip.js +54 -0
  34. package/dist/components/chip/Chip.js.map +1 -0
  35. package/dist/components/chip/index.d.ts +3 -0
  36. package/dist/components/chip/index.d.ts.map +1 -0
  37. package/dist/components/chip/index.js +2 -0
  38. package/dist/components/chip/index.js.map +1 -0
  39. package/dist/components/chip/resolveChipColors.d.ts +10 -0
  40. package/dist/components/chip/resolveChipColors.d.ts.map +1 -0
  41. package/dist/components/chip/resolveChipColors.js +47 -0
  42. package/dist/components/chip/resolveChipColors.js.map +1 -0
  43. package/dist/components/chip/types.d.ts +26 -0
  44. package/dist/components/chip/types.d.ts.map +1 -0
  45. package/dist/components/chip/types.js +2 -0
  46. package/dist/components/chip/types.js.map +1 -0
  47. package/dist/components/chip-group/ChipGroup.d.ts +4 -0
  48. package/dist/components/chip-group/ChipGroup.d.ts.map +1 -0
  49. package/dist/components/chip-group/ChipGroup.js +32 -0
  50. package/dist/components/chip-group/ChipGroup.js.map +1 -0
  51. package/dist/components/chip-group/index.d.ts +3 -0
  52. package/dist/components/chip-group/index.d.ts.map +1 -0
  53. package/dist/components/chip-group/index.js +2 -0
  54. package/dist/components/chip-group/index.js.map +1 -0
  55. package/dist/components/chip-group/types.d.ts +31 -0
  56. package/dist/components/chip-group/types.d.ts.map +1 -0
  57. package/dist/components/chip-group/types.js +2 -0
  58. package/dist/components/chip-group/types.js.map +1 -0
  59. package/dist/components/input/Input.d.ts.map +1 -1
  60. package/dist/components/input/Input.js +3 -2
  61. package/dist/components/input/Input.js.map +1 -1
  62. package/dist/components/input/index.d.ts +1 -1
  63. package/dist/components/input/index.d.ts.map +1 -1
  64. package/dist/components/input/index.js.map +1 -1
  65. package/dist/components/input/types.d.ts +15 -2
  66. package/dist/components/input/types.d.ts.map +1 -1
  67. package/dist/components/input/types.js.map +1 -1
  68. package/dist/components/search-bar/SearchBar.d.ts +4 -0
  69. package/dist/components/search-bar/SearchBar.d.ts.map +1 -0
  70. package/dist/components/search-bar/SearchBar.js +18 -0
  71. package/dist/components/search-bar/SearchBar.js.map +1 -0
  72. package/dist/components/search-bar/index.d.ts +3 -0
  73. package/dist/components/search-bar/index.d.ts.map +1 -0
  74. package/dist/components/search-bar/index.js +2 -0
  75. package/dist/components/search-bar/index.js.map +1 -0
  76. package/dist/components/search-bar/types.d.ts +14 -0
  77. package/dist/components/search-bar/types.d.ts.map +1 -0
  78. package/dist/components/search-bar/types.js +2 -0
  79. package/dist/components/search-bar/types.js.map +1 -0
  80. package/dist/index.d.ts +15 -1
  81. package/dist/index.d.ts.map +1 -1
  82. package/dist/index.js +7 -0
  83. package/dist/index.js.map +1 -1
  84. package/dist/patterns/filter-bar/FilterBar.d.ts +4 -0
  85. package/dist/patterns/filter-bar/FilterBar.d.ts.map +1 -0
  86. package/dist/patterns/filter-bar/FilterBar.js +12 -0
  87. package/dist/patterns/filter-bar/FilterBar.js.map +1 -0
  88. package/dist/patterns/filter-bar/index.d.ts +3 -0
  89. package/dist/patterns/filter-bar/index.d.ts.map +1 -0
  90. package/dist/patterns/filter-bar/index.js +2 -0
  91. package/dist/patterns/filter-bar/index.js.map +1 -0
  92. package/dist/patterns/filter-bar/types.d.ts +9 -0
  93. package/dist/patterns/filter-bar/types.d.ts.map +1 -0
  94. package/dist/patterns/filter-bar/types.js +2 -0
  95. package/dist/patterns/filter-bar/types.js.map +1 -0
  96. package/dist/patterns/list/List.d.ts +4 -0
  97. package/dist/patterns/list/List.d.ts.map +1 -0
  98. package/dist/patterns/list/List.js +35 -0
  99. package/dist/patterns/list/List.js.map +1 -0
  100. package/dist/patterns/list/ListRow.d.ts +4 -0
  101. package/dist/patterns/list/ListRow.d.ts.map +1 -0
  102. package/dist/patterns/list/ListRow.js +108 -0
  103. package/dist/patterns/list/ListRow.js.map +1 -0
  104. package/dist/patterns/list/ListSection.d.ts +4 -0
  105. package/dist/patterns/list/ListSection.d.ts.map +1 -0
  106. package/dist/patterns/list/ListSection.js +14 -0
  107. package/dist/patterns/list/ListSection.js.map +1 -0
  108. package/dist/patterns/list/index.d.ts +5 -0
  109. package/dist/patterns/list/index.d.ts.map +1 -0
  110. package/dist/patterns/list/index.js +4 -0
  111. package/dist/patterns/list/index.js.map +1 -0
  112. package/dist/patterns/list/resolveListSeparator.d.ts +5 -0
  113. package/dist/patterns/list/resolveListSeparator.d.ts.map +1 -0
  114. package/dist/patterns/list/resolveListSeparator.js +6 -0
  115. package/dist/patterns/list/resolveListSeparator.js.map +1 -0
  116. package/dist/patterns/list/types.d.ts +55 -0
  117. package/dist/patterns/list/types.d.ts.map +1 -0
  118. package/dist/patterns/list/types.js +2 -0
  119. package/dist/patterns/list/types.js.map +1 -0
  120. package/package.json +1 -1
  121. package/src/components/avatar/Avatar.tsx +133 -0
  122. package/src/components/avatar/index.ts +3 -0
  123. package/src/components/avatar/resolveAvatarInitials.test.ts +27 -0
  124. package/src/components/avatar/resolveAvatarInitials.ts +46 -0
  125. package/src/components/avatar/types.ts +20 -0
  126. package/src/components/avatar-group/AvatarGroup.tsx +74 -0
  127. package/src/components/avatar-group/index.ts +2 -0
  128. package/src/components/avatar-group/types.ts +24 -0
  129. package/src/components/chip/Chip.tsx +95 -0
  130. package/src/components/chip/index.ts +2 -0
  131. package/src/components/chip/resolveChipColors.ts +65 -0
  132. package/src/components/chip/types.ts +29 -0
  133. package/src/components/chip-group/ChipGroup.tsx +66 -0
  134. package/src/components/chip-group/index.ts +2 -0
  135. package/src/components/chip-group/types.ts +36 -0
  136. package/src/components/input/Input.tsx +17 -1
  137. package/src/components/input/index.ts +1 -1
  138. package/src/components/input/types.ts +19 -2
  139. package/src/components/search-bar/SearchBar.tsx +50 -0
  140. package/src/components/search-bar/index.ts +2 -0
  141. package/src/components/search-bar/types.ts +14 -0
  142. package/src/index.ts +22 -1
  143. package/src/patterns/filter-bar/FilterBar.tsx +25 -0
  144. package/src/patterns/filter-bar/index.ts +2 -0
  145. package/src/patterns/filter-bar/types.ts +10 -0
  146. package/src/patterns/list/List.tsx +72 -0
  147. package/src/patterns/list/ListRow.tsx +193 -0
  148. package/src/patterns/list/ListSection.tsx +36 -0
  149. package/src/patterns/list/index.ts +11 -0
  150. package/src/patterns/list/resolveListSeparator.test.ts +18 -0
  151. package/src/patterns/list/resolveListSeparator.ts +8 -0
  152. package/src/patterns/list/types.ts +67 -0
  153. package/src/showcaseCoverage.test.ts +9 -0
  154. package/src/theme/themeScopeStructure.test.ts +4 -0
@@ -0,0 +1,133 @@
1
+ import type { RoleSemantics, SurfaceTheme } from '@ankhorage/surface';
2
+ import React from 'react';
3
+ import { Image } from 'react-native';
4
+
5
+ import { Box } from '../../foundation';
6
+ import type { ZoraTone } from '../../internal/recipes';
7
+ import { useZoraTheme } from '../../theme/useZoraTheme';
8
+ import { withZoraThemeScope } from '../../theme/withZoraThemeScope';
9
+ import { Icon } from '../icon';
10
+ import { Text, type TextTone, type TextVariant } from '../text';
11
+ import { resolveAvatarInitials } from './resolveAvatarInitials';
12
+ import type { AvatarProps, AvatarShape, AvatarSize } from './types';
13
+
14
+ const AVATAR_SIZES: Record<AvatarSize, number> = {
15
+ xs: 24,
16
+ s: 32,
17
+ m: 40,
18
+ l: 48,
19
+ xl: 64,
20
+ };
21
+
22
+ function resolveRoleSemantics(theme: SurfaceTheme, tone: ZoraTone): RoleSemantics {
23
+ switch (tone) {
24
+ case 'primary':
25
+ return theme.semantics.action.primary;
26
+ case 'danger':
27
+ return theme.semantics.action.danger;
28
+ case 'success':
29
+ return theme.semantics.success;
30
+ case 'warning':
31
+ return theme.semantics.warning;
32
+ case 'neutral':
33
+ default:
34
+ return theme.semantics.action.neutral;
35
+ }
36
+ }
37
+
38
+ function resolveTextTone(tone: ZoraTone): TextTone {
39
+ return tone === 'neutral' ? 'default' : tone;
40
+ }
41
+
42
+ function resolveTextVariant(size: AvatarSize): TextVariant {
43
+ switch (size) {
44
+ case 'xs':
45
+ case 's':
46
+ return 'caption';
47
+ case 'l':
48
+ return 'bodySmall';
49
+ case 'xl':
50
+ return 'lead';
51
+ case 'm':
52
+ default:
53
+ return 'label';
54
+ }
55
+ }
56
+
57
+ function resolveRadius(shape: AvatarShape): 'full' | 'l' {
58
+ return shape === 'circle' ? 'full' : 'l';
59
+ }
60
+
61
+ function AvatarInner({
62
+ themeId: _themeId,
63
+ mode: _mode,
64
+ testID,
65
+ source,
66
+ name,
67
+ initials,
68
+ iconFallback,
69
+ label,
70
+ size = 'm',
71
+ shape = 'circle',
72
+ tone = 'neutral',
73
+ }: AvatarProps) {
74
+ const { theme } = useZoraTheme();
75
+ const resolvedSize = AVATAR_SIZES[size];
76
+ const resolvedInitials = initials ?? resolveAvatarInitials(name);
77
+ const role = resolveRoleSemantics(theme, tone);
78
+ const backgroundColor = tone === 'neutral' ? theme.semantics.neutral.surface : role.softBg;
79
+ const contentColor = tone === 'neutral' ? theme.semantics.content.default : role.base;
80
+ const radius = resolveRadius(shape);
81
+
82
+ const renderFallback = () => {
83
+ if (resolvedInitials) {
84
+ return (
85
+ <Text tone={resolveTextTone(tone)} variant={resolveTextVariant(size)} weight="semiBold">
86
+ {resolvedInitials}
87
+ </Text>
88
+ );
89
+ }
90
+
91
+ if (iconFallback) {
92
+ const iconSize = Math.max(16, Math.round(resolvedSize * 0.48));
93
+ return (
94
+ <Icon
95
+ color={contentColor}
96
+ name={iconFallback.name}
97
+ provider={iconFallback.provider}
98
+ size={iconSize}
99
+ />
100
+ );
101
+ }
102
+
103
+ return null;
104
+ };
105
+
106
+ return (
107
+ <Box
108
+ accessibilityLabel={label}
109
+ bg={backgroundColor}
110
+ height={resolvedSize}
111
+ radius={radius}
112
+ testID={testID}
113
+ width={resolvedSize}
114
+ style={{
115
+ alignItems: 'center',
116
+ justifyContent: 'center',
117
+ overflow: 'hidden',
118
+ }}
119
+ >
120
+ {source ? (
121
+ <Image
122
+ accessibilityLabel={label}
123
+ source={source}
124
+ style={{ height: '100%', width: '100%' }}
125
+ />
126
+ ) : (
127
+ renderFallback()
128
+ )}
129
+ </Box>
130
+ );
131
+ }
132
+
133
+ export const Avatar = withZoraThemeScope(AvatarInner);
@@ -0,0 +1,3 @@
1
+ export { Avatar } from './Avatar';
2
+ export { resolveAvatarInitials } from './resolveAvatarInitials';
3
+ export type { AvatarProps, AvatarShape, AvatarSize } from './types';
@@ -0,0 +1,27 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+
3
+ import { resolveAvatarInitials } from './resolveAvatarInitials';
4
+
5
+ describe('resolveAvatarInitials', () => {
6
+ it('returns null for empty input', () => {
7
+ expect(resolveAvatarInitials(undefined)).toBeNull();
8
+ expect(resolveAvatarInitials('')).toBeNull();
9
+ expect(resolveAvatarInitials(' ')).toBeNull();
10
+ });
11
+
12
+ it('returns the first two characters for a single word', () => {
13
+ expect(resolveAvatarInitials('Zora')).toBe('ZO');
14
+ expect(resolveAvatarInitials('a')).toBe('A');
15
+ });
16
+
17
+ it('returns first+last initials for multiple words', () => {
18
+ expect(resolveAvatarInitials('Fabio Gartenmann')).toBe('FG');
19
+ expect(resolveAvatarInitials('Fabio Gartenmann')).toBe('FG');
20
+ expect(resolveAvatarInitials('Fabio Gartenmann Example')).toBe('FE');
21
+ });
22
+
23
+ it('handles non-letter characters', () => {
24
+ expect(resolveAvatarInitials('🧠 Brain')).toBe('🧠B');
25
+ expect(resolveAvatarInitials('Brain 🧠')).toBe('B🧠');
26
+ });
27
+ });
@@ -0,0 +1,46 @@
1
+ function takeFirstCharacter(value: string): string | null {
2
+ const trimmed = value.trim();
3
+ if (!trimmed) return null;
4
+
5
+ const chars = Array.from(trimmed);
6
+ return chars[0] ? chars[0].toUpperCase() : null;
7
+ }
8
+
9
+ function takeFirstTwoCharacters(value: string): string | null {
10
+ const trimmed = value.trim();
11
+ if (!trimmed) return null;
12
+
13
+ const chars = Array.from(trimmed);
14
+ const [first, second] = chars;
15
+ if (!first) return null;
16
+
17
+ return `${first}${second ?? ''}`.toUpperCase();
18
+ }
19
+
20
+ export function resolveAvatarInitials(name: string | undefined): string | null {
21
+ if (!name) return null;
22
+
23
+ const parts = name
24
+ .trim()
25
+ .split(/\s+/)
26
+ .map((part) => part.trim())
27
+ .filter(Boolean);
28
+
29
+ if (parts.length === 0) return null;
30
+
31
+ if (parts.length === 1) {
32
+ const [part] = parts;
33
+ return part ? takeFirstTwoCharacters(part) : null;
34
+ }
35
+
36
+ const [firstPart] = parts;
37
+ const [lastPart] = parts.slice(-1);
38
+ const first = firstPart ? takeFirstCharacter(firstPart) : null;
39
+ const last = lastPart ? takeFirstCharacter(lastPart) : null;
40
+
41
+ if (!first && !last) return null;
42
+ if (!last) return first;
43
+ if (!first) return last;
44
+
45
+ return `${first}${last}`;
46
+ }
@@ -0,0 +1,20 @@
1
+ import type { ButtonIconSpec } from '@ankhorage/surface';
2
+ import type { ImageSourcePropType } from 'react-native';
3
+
4
+ import type { ZoraTone } from '../../internal/recipes';
5
+ import type { ZoraBaseProps } from '../../theme/ZoraBaseProps';
6
+
7
+ export type AvatarSize = 'xs' | 's' | 'm' | 'l' | 'xl';
8
+
9
+ export type AvatarShape = 'circle' | 'rounded';
10
+
11
+ export interface AvatarProps extends ZoraBaseProps {
12
+ source?: ImageSourcePropType;
13
+ name?: string;
14
+ initials?: string;
15
+ iconFallback?: ButtonIconSpec;
16
+ label?: string;
17
+ size?: AvatarSize;
18
+ shape?: AvatarShape;
19
+ tone?: ZoraTone;
20
+ }
@@ -0,0 +1,74 @@
1
+ import React from 'react';
2
+
3
+ import { Box, Stack } from '../../foundation';
4
+ import { useZoraTheme } from '../../theme/useZoraTheme';
5
+ import { withZoraThemeScope } from '../../theme/withZoraThemeScope';
6
+ import { Avatar } from '../avatar';
7
+ import type { AvatarGroupItem, AvatarGroupProps } from './types';
8
+
9
+ function defaultOverflowLabel(overflowCount: number): string {
10
+ return `+${overflowCount}`;
11
+ }
12
+
13
+ function AvatarGroupInner({
14
+ themeId: _themeId,
15
+ mode: _mode,
16
+ testID,
17
+ items,
18
+ max = 4,
19
+ size = 's',
20
+ shape = 'circle',
21
+ overflowLabel = defaultOverflowLabel,
22
+ }: AvatarGroupProps) {
23
+ const { theme } = useZoraTheme();
24
+
25
+ const visibleItems = items.slice(0, max);
26
+ const overflowCount = Math.max(0, items.length - visibleItems.length);
27
+ const overlap =
28
+ size === 'xs' ? 8 : size === 's' ? 10 : size === 'm' ? 12 : size === 'l' ? 14 : 16;
29
+ const borderColor = theme.semantics.surface.default;
30
+
31
+ const renderItem = (item: AvatarGroupItem, index: number) => (
32
+ <Box
33
+ key={item.id ?? `${index}`}
34
+ ml={index === 0 ? 0 : -overlap}
35
+ radius="full"
36
+ borderWidth={2}
37
+ borderColor={borderColor}
38
+ >
39
+ <Avatar
40
+ iconFallback={item.iconFallback}
41
+ initials={item.initials}
42
+ label={item.label}
43
+ name={item.name}
44
+ shape={shape}
45
+ size={size}
46
+ source={item.source}
47
+ tone={item.tone}
48
+ />
49
+ </Box>
50
+ );
51
+
52
+ return (
53
+ <Stack align="center" direction="row" testID={testID} wrap="nowrap">
54
+ {visibleItems.map(renderItem)}
55
+ {overflowCount > 0 ? (
56
+ <Box
57
+ ml={visibleItems.length === 0 ? 0 : -overlap}
58
+ radius="full"
59
+ borderWidth={2}
60
+ borderColor={borderColor}
61
+ >
62
+ <Avatar
63
+ initials={overflowLabel(overflowCount)}
64
+ size={size}
65
+ shape={shape}
66
+ tone="neutral"
67
+ />
68
+ </Box>
69
+ ) : null}
70
+ </Stack>
71
+ );
72
+ }
73
+
74
+ export const AvatarGroup = withZoraThemeScope(AvatarGroupInner);
@@ -0,0 +1,2 @@
1
+ export { AvatarGroup } from './AvatarGroup';
2
+ export type { AvatarGroupItem, AvatarGroupProps } from './types';
@@ -0,0 +1,24 @@
1
+ import type { ButtonIconSpec } from '@ankhorage/surface';
2
+ import type { ImageSourcePropType } from 'react-native';
3
+
4
+ import type { ZoraTone } from '../../internal/recipes';
5
+ import type { ZoraBaseProps } from '../../theme/ZoraBaseProps';
6
+ import type { AvatarShape, AvatarSize } from '../avatar';
7
+
8
+ export interface AvatarGroupItem {
9
+ id?: string;
10
+ source?: ImageSourcePropType;
11
+ name?: string;
12
+ initials?: string;
13
+ iconFallback?: ButtonIconSpec;
14
+ label?: string;
15
+ tone?: ZoraTone;
16
+ }
17
+
18
+ export interface AvatarGroupProps extends ZoraBaseProps {
19
+ items: readonly AvatarGroupItem[];
20
+ max?: number;
21
+ size?: AvatarSize;
22
+ shape?: AvatarShape;
23
+ overflowLabel?: (overflowCount: number) => string;
24
+ }
@@ -0,0 +1,95 @@
1
+ import { ButtonBase } from '@ankhorage/surface';
2
+ import React from 'react';
3
+
4
+ import { Box, Inline } from '../../foundation';
5
+ import { resolveIconSize } from '../../internal/recipes';
6
+ import { useZoraTheme } from '../../theme/useZoraTheme';
7
+ import { withZoraThemeScope } from '../../theme/withZoraThemeScope';
8
+ import { Icon } from '../icon';
9
+ import { Text } from '../text';
10
+ import { resolveChipColors } from './resolveChipColors';
11
+ import type { ChipInteractionState, ChipProps } from './types';
12
+
13
+ function resolveChipPadding(size: NonNullable<ChipProps['size']>): {
14
+ px: 's' | 'm';
15
+ py: 'xxs' | 'xs';
16
+ } {
17
+ switch (size) {
18
+ case 's':
19
+ return { px: 's', py: 'xxs' };
20
+ case 'm':
21
+ return { px: 'm', py: 'xs' };
22
+ case 'l':
23
+ default:
24
+ return { px: 'm', py: 'xs' };
25
+ }
26
+ }
27
+
28
+ function ChipInner({
29
+ themeId: _themeId,
30
+ mode: _mode,
31
+ testID,
32
+ children,
33
+ icon,
34
+ selected = false,
35
+ disabled = false,
36
+ tone = 'neutral',
37
+ size = 's',
38
+ onPress,
39
+ }: ChipProps) {
40
+ const { theme } = useZoraTheme();
41
+ const padding = resolveChipPadding(size);
42
+ const iconSize = resolveIconSize(size);
43
+
44
+ const renderContent = (state: ChipInteractionState) => {
45
+ const colors = resolveChipColors({ theme, tone, selected, state });
46
+ const textTone = state.disabled
47
+ ? 'muted'
48
+ : selected
49
+ ? tone === 'neutral'
50
+ ? 'default'
51
+ : tone
52
+ : 'default';
53
+
54
+ return (
55
+ <Box
56
+ bg={colors.backgroundColor}
57
+ borderColor={colors.borderColor}
58
+ borderWidth={1}
59
+ px={padding.px}
60
+ py={padding.py}
61
+ radius="full"
62
+ style={{
63
+ alignSelf: 'flex-start',
64
+ opacity: colors.opacity,
65
+ }}
66
+ >
67
+ <Inline align="center" gap="xs" wrap="nowrap">
68
+ {icon ? (
69
+ <Icon
70
+ color={colors.contentColor}
71
+ name={icon.name}
72
+ provider={icon.provider}
73
+ size={iconSize}
74
+ />
75
+ ) : null}
76
+ <Text tone={textTone} variant="label">
77
+ {children}
78
+ </Text>
79
+ </Inline>
80
+ </Box>
81
+ );
82
+ };
83
+
84
+ if (!onPress) {
85
+ return renderContent({ disabled, focused: false, hovered: false, pressed: false });
86
+ }
87
+
88
+ return (
89
+ <ButtonBase disabled={disabled} onPress={onPress} radius="full" testID={testID}>
90
+ {(state) => renderContent(state)}
91
+ </ButtonBase>
92
+ );
93
+ }
94
+
95
+ export const Chip = withZoraThemeScope(ChipInner);
@@ -0,0 +1,2 @@
1
+ export { Chip } from './Chip';
2
+ export type { ChipProps } from './types';
@@ -0,0 +1,65 @@
1
+ import type { RoleSemantics, SurfaceTheme } from '@ankhorage/surface';
2
+
3
+ import type { ZoraTone } from '../../internal/recipes';
4
+ import type { ChipColors, ChipInteractionState } from './types';
5
+
6
+ function resolveChipRole(theme: SurfaceTheme, tone: ZoraTone): RoleSemantics {
7
+ switch (tone) {
8
+ case 'primary':
9
+ return theme.semantics.action.primary;
10
+ case 'danger':
11
+ return theme.semantics.action.danger;
12
+ case 'success':
13
+ return theme.semantics.success;
14
+ case 'warning':
15
+ return theme.semantics.warning;
16
+ case 'neutral':
17
+ default:
18
+ return theme.semantics.action.neutral;
19
+ }
20
+ }
21
+
22
+ export function resolveChipColors({
23
+ theme,
24
+ tone,
25
+ selected,
26
+ state,
27
+ }: {
28
+ theme: SurfaceTheme;
29
+ tone: ZoraTone;
30
+ selected: boolean;
31
+ state: ChipInteractionState;
32
+ }): ChipColors {
33
+ if (state.disabled) {
34
+ return {
35
+ backgroundColor: theme.semantics.neutral.surface,
36
+ borderColor: theme.semantics.neutral.divider,
37
+ contentColor: theme.semantics.content.muted,
38
+ opacity: 0.72,
39
+ };
40
+ }
41
+
42
+ const role = resolveChipRole(theme, tone);
43
+
44
+ if (selected) {
45
+ return {
46
+ backgroundColor: state.pressed
47
+ ? role.softActive
48
+ : state.hovered
49
+ ? role.softHover
50
+ : role.softBg,
51
+ borderColor: 'transparent',
52
+ contentColor: role.base,
53
+ };
54
+ }
55
+
56
+ return {
57
+ backgroundColor: state.pressed
58
+ ? theme.semantics.neutral.surfaceActive
59
+ : state.hovered
60
+ ? theme.semantics.neutral.surfaceHover
61
+ : theme.semantics.neutral.surface,
62
+ borderColor: theme.semantics.neutral.divider,
63
+ contentColor: theme.semantics.content.default,
64
+ };
65
+ }
@@ -0,0 +1,29 @@
1
+ import type { ButtonIconSpec } from '@ankhorage/surface';
2
+ import type React from 'react';
3
+
4
+ import type { ZoraControlSize, ZoraTone } from '../../internal/recipes';
5
+ import type { ZoraBaseProps } from '../../theme/ZoraBaseProps';
6
+
7
+ export interface ChipProps extends ZoraBaseProps {
8
+ children: React.ReactNode;
9
+ icon?: ButtonIconSpec;
10
+ selected?: boolean;
11
+ disabled?: boolean;
12
+ tone?: ZoraTone;
13
+ size?: ZoraControlSize;
14
+ onPress?: () => void;
15
+ }
16
+
17
+ export interface ChipColors {
18
+ backgroundColor: string;
19
+ borderColor: string;
20
+ contentColor: string;
21
+ opacity?: number;
22
+ }
23
+
24
+ export interface ChipInteractionState {
25
+ pressed: boolean;
26
+ hovered: boolean;
27
+ focused: boolean;
28
+ disabled: boolean;
29
+ }
@@ -0,0 +1,66 @@
1
+ import React from 'react';
2
+
3
+ import { Inline } from '../../foundation';
4
+ import { withZoraThemeScope } from '../../theme/withZoraThemeScope';
5
+ import { Chip } from '../chip';
6
+ import type { ChipGroupItem, ChipGroupProps } from './types';
7
+
8
+ function hasValue<TValue extends string>(values: readonly TValue[], value: TValue): boolean {
9
+ return values.includes(value);
10
+ }
11
+
12
+ function toggleValue<TValue extends string>(values: readonly TValue[], value: TValue): TValue[] {
13
+ return hasValue(values, value) ? values.filter((item) => item !== value) : [...values, value];
14
+ }
15
+
16
+ function ChipGroupInner<TValue extends string = string>({
17
+ themeId: _themeId,
18
+ mode: _mode,
19
+ testID,
20
+ items,
21
+ value,
22
+ onValueChange,
23
+ multiple,
24
+ tone = 'neutral',
25
+ size = 's',
26
+ wrap = true,
27
+ disabled,
28
+ }: ChipGroupProps<TValue>) {
29
+ const renderChip = (item: ChipGroupItem<TValue>) => {
30
+ const itemDisabled = disabled ?? item.disabled ?? false;
31
+ const isSelected = Array.isArray(value) ? hasValue(value, item.value) : value === item.value;
32
+
33
+ const handlePress = () => {
34
+ if (multiple) {
35
+ const next = toggleValue(value, item.value);
36
+ onValueChange(next);
37
+ return;
38
+ }
39
+
40
+ onValueChange(item.value);
41
+ };
42
+
43
+ return (
44
+ <Chip
45
+ key={item.value}
46
+ disabled={itemDisabled}
47
+ icon={item.icon}
48
+ onPress={handlePress}
49
+ selected={isSelected}
50
+ size={size}
51
+ testID={item.testID}
52
+ tone={tone}
53
+ >
54
+ {item.label}
55
+ </Chip>
56
+ );
57
+ };
58
+
59
+ return (
60
+ <Inline align="center" gap="s" testID={testID} wrap={wrap ? 'wrap' : 'nowrap'}>
61
+ {items.map(renderChip)}
62
+ </Inline>
63
+ );
64
+ }
65
+
66
+ export const ChipGroup = withZoraThemeScope(ChipGroupInner);
@@ -0,0 +1,2 @@
1
+ export { ChipGroup } from './ChipGroup';
2
+ export type { ChipGroupItem, ChipGroupProps } from './types';
@@ -0,0 +1,36 @@
1
+ import type { ButtonIconSpec } from '@ankhorage/surface';
2
+ import type React from 'react';
3
+
4
+ import type { ZoraControlSize, ZoraTone } from '../../internal/recipes';
5
+ import type { ZoraBaseProps } from '../../theme/ZoraBaseProps';
6
+
7
+ export interface ChipGroupItem<TValue extends string = string> {
8
+ value: TValue;
9
+ label: React.ReactNode;
10
+ icon?: ButtonIconSpec;
11
+ disabled?: boolean;
12
+ testID?: string;
13
+ }
14
+
15
+ interface ChipGroupBaseProps<TValue extends string> extends ZoraBaseProps {
16
+ items: readonly ChipGroupItem<TValue>[];
17
+ tone?: ZoraTone;
18
+ size?: ZoraControlSize;
19
+ wrap?: boolean;
20
+ disabled?: boolean;
21
+ }
22
+
23
+ interface ChipGroupSingleProps<TValue extends string> {
24
+ multiple?: false;
25
+ value: TValue;
26
+ onValueChange: (value: TValue) => void;
27
+ }
28
+
29
+ interface ChipGroupMultipleProps<TValue extends string> {
30
+ multiple: true;
31
+ value: readonly TValue[];
32
+ onValueChange: (value: TValue[]) => void;
33
+ }
34
+
35
+ export type ChipGroupProps<TValue extends string = string> = ChipGroupBaseProps<TValue> &
36
+ (ChipGroupSingleProps<TValue> | ChipGroupMultipleProps<TValue>);
@@ -4,6 +4,7 @@ import React from 'react';
4
4
  import { resolveIconSize } from '../../internal/recipes';
5
5
  import { useZoraTheme } from '../../theme/useZoraTheme';
6
6
  import { withZoraThemeScope } from '../../theme/withZoraThemeScope';
7
+ import { IconButton } from '../icon-button';
7
8
  import type { InputProps } from './types';
8
9
 
9
10
  function InputInner({
@@ -12,6 +13,9 @@ function InputInner({
12
13
  size = 'l',
13
14
  leadingIcon,
14
15
  trailingIcon,
16
+ trailingAction,
17
+ disabled,
18
+ readOnly,
15
19
  ...props
16
20
  }: InputProps) {
17
21
  const { theme } = useZoraTheme();
@@ -21,6 +25,7 @@ function InputInner({
21
25
  return (
22
26
  <Surface.TextInput
23
27
  {...props}
28
+ disabled={disabled}
24
29
  leadingAccessory={
25
30
  leadingIcon ? (
26
31
  <Surface.Icon
@@ -31,9 +36,20 @@ function InputInner({
31
36
  />
32
37
  ) : undefined
33
38
  }
39
+ readOnly={readOnly}
34
40
  size={size}
35
41
  trailingAccessory={
36
- trailingIcon ? (
42
+ trailingAction ? (
43
+ <IconButton
44
+ icon={trailingAction.icon}
45
+ label={trailingAction.label}
46
+ disabled={disabled ?? readOnly}
47
+ emphasis="ghost"
48
+ size={size === 'l' ? 'm' : size}
49
+ tone="neutral"
50
+ onPress={trailingAction.onPress}
51
+ />
52
+ ) : trailingIcon ? (
37
53
  <Surface.Icon
38
54
  color={iconColor}
39
55
  name={trailingIcon.name}
@@ -1,2 +1,2 @@
1
1
  export { Input } from './Input';
2
- export type { InputProps } from './types';
2
+ export type { InputProps, InputTrailingAction } from './types';