@butternutbox/pawprint-native 0.0.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.
Files changed (105) hide show
  1. package/.turbo/turbo-build.log +30 -0
  2. package/COMPONENT_GUIDELINES.md +610 -0
  3. package/README.md +72 -0
  4. package/dist/ibm-plex-sans-condensed-400-normal-I2XLJNNB.woff2 +0 -0
  5. package/dist/ibm-plex-sans-condensed-500-normal-IEQBNVGX.woff2 +0 -0
  6. package/dist/ibm-plex-sans-condensed-600-normal-UX5ZU5T6.woff2 +0 -0
  7. package/dist/ibm-plex-sans-condensed-700-normal-4PFYFTSO.woff2 +0 -0
  8. package/dist/ida-narrow-500-normal-C6I2PK4T.woff2 +0 -0
  9. package/dist/ida-narrow-700-normal-UPHPRIN6.woff2 +0 -0
  10. package/dist/index.cjs +2686 -0
  11. package/dist/index.cjs.map +1 -0
  12. package/dist/index.d.cts +780 -0
  13. package/dist/index.d.ts +780 -0
  14. package/dist/index.js +2617 -0
  15. package/dist/index.js.map +1 -0
  16. package/eslint.config.js +3 -0
  17. package/llms.txt +458 -0
  18. package/package.json +57 -0
  19. package/src/components/atoms/Avatar/Avatar.stories.tsx +125 -0
  20. package/src/components/atoms/Avatar/Avatar.tsx +159 -0
  21. package/src/components/atoms/Avatar/index.ts +7 -0
  22. package/src/components/atoms/Badge/Badge.stories.tsx +231 -0
  23. package/src/components/atoms/Badge/Badge.tsx +184 -0
  24. package/src/components/atoms/Badge/index.ts +2 -0
  25. package/src/components/atoms/Button/Button.stories.tsx +145 -0
  26. package/src/components/atoms/Button/Button.tsx +261 -0
  27. package/src/components/atoms/Button/index.ts +7 -0
  28. package/src/components/atoms/Hint/Hint.stories.tsx +84 -0
  29. package/src/components/atoms/Hint/Hint.tsx +59 -0
  30. package/src/components/atoms/Hint/index.ts +2 -0
  31. package/src/components/atoms/Icon/Icon.stories.tsx +200 -0
  32. package/src/components/atoms/Icon/Icon.tsx +112 -0
  33. package/src/components/atoms/Icon/index.ts +8 -0
  34. package/src/components/atoms/IconButton/IconButton.stories.tsx +162 -0
  35. package/src/components/atoms/IconButton/IconButton.tsx +227 -0
  36. package/src/components/atoms/IconButton/index.ts +7 -0
  37. package/src/components/atoms/Illustration/Illustration.stories.tsx +167 -0
  38. package/src/components/atoms/Illustration/Illustration.tsx +81 -0
  39. package/src/components/atoms/Illustration/index.ts +6 -0
  40. package/src/components/atoms/Input/Input.stories.tsx +142 -0
  41. package/src/components/atoms/Input/Input.tsx +110 -0
  42. package/src/components/atoms/Input/InputDescription.tsx +49 -0
  43. package/src/components/atoms/Input/InputError.tsx +39 -0
  44. package/src/components/atoms/Input/InputField.tsx +119 -0
  45. package/src/components/atoms/Input/InputLabel.tsx +61 -0
  46. package/src/components/atoms/Input/index.ts +10 -0
  47. package/src/components/atoms/Link/Link.stories.tsx +119 -0
  48. package/src/components/atoms/Link/Link.tsx +118 -0
  49. package/src/components/atoms/Link/index.ts +2 -0
  50. package/src/components/atoms/Logo/Logo.registry.ts +39 -0
  51. package/src/components/atoms/Logo/Logo.tsx +68 -0
  52. package/src/components/atoms/Logo/index.ts +4 -0
  53. package/src/components/atoms/Spinner/Spinner.stories.tsx +98 -0
  54. package/src/components/atoms/Spinner/Spinner.tsx +91 -0
  55. package/src/components/atoms/Spinner/index.ts +2 -0
  56. package/src/components/atoms/Switch/Switch.stories.tsx +120 -0
  57. package/src/components/atoms/Switch/Switch.tsx +196 -0
  58. package/src/components/atoms/Switch/index.ts +2 -0
  59. package/src/components/atoms/Tag/Tag.stories.tsx +89 -0
  60. package/src/components/atoms/Tag/Tag.tsx +122 -0
  61. package/src/components/atoms/Tag/index.ts +2 -0
  62. package/src/components/atoms/Typography/Typography.stories.tsx +315 -0
  63. package/src/components/atoms/Typography/Typography.tsx +284 -0
  64. package/src/components/atoms/Typography/index.ts +2 -0
  65. package/src/components/atoms/index.ts +14 -0
  66. package/src/components/index.ts +2 -0
  67. package/src/components/molecules/ButtonDock/ButtonDock.stories.tsx +95 -0
  68. package/src/components/molecules/ButtonDock/ButtonDock.tsx +148 -0
  69. package/src/components/molecules/ButtonDock/index.ts +2 -0
  70. package/src/components/molecules/ButtonGroup/ButtonGroup.stories.tsx +82 -0
  71. package/src/components/molecules/ButtonGroup/ButtonGroup.tsx +94 -0
  72. package/src/components/molecules/ButtonGroup/index.ts +2 -0
  73. package/src/components/molecules/Checkbox/Checkbox.stories.tsx +148 -0
  74. package/src/components/molecules/Checkbox/Checkbox.tsx +279 -0
  75. package/src/components/molecules/Checkbox/CheckboxGroup.tsx +53 -0
  76. package/src/components/molecules/Checkbox/index.ts +4 -0
  77. package/src/components/molecules/Radio/Radio.stories.tsx +182 -0
  78. package/src/components/molecules/Radio/Radio.tsx +249 -0
  79. package/src/components/molecules/Radio/RadioGroup.tsx +142 -0
  80. package/src/components/molecules/Radio/index.ts +4 -0
  81. package/src/components/molecules/SegmentedControl/SegmentedControl.stories.tsx +151 -0
  82. package/src/components/molecules/SegmentedControl/SegmentedControl.tsx +323 -0
  83. package/src/components/molecules/SegmentedControl/index.ts +5 -0
  84. package/src/components/molecules/Slider/Slider.stories.tsx +144 -0
  85. package/src/components/molecules/Slider/Slider.tsx +303 -0
  86. package/src/components/molecules/Slider/index.ts +2 -0
  87. package/src/components/molecules/index.ts +6 -0
  88. package/src/fonts/ibm-plex-sans-condensed-400-normal.woff2 +0 -0
  89. package/src/fonts/ibm-plex-sans-condensed-500-normal.woff2 +0 -0
  90. package/src/fonts/ibm-plex-sans-condensed-600-normal.woff2 +0 -0
  91. package/src/fonts/ibm-plex-sans-condensed-700-normal.woff2 +0 -0
  92. package/src/fonts/ida-narrow-500-normal.woff2 +0 -0
  93. package/src/fonts/ida-narrow-700-normal.woff2 +0 -0
  94. package/src/fonts/index.ts +49 -0
  95. package/src/index.ts +9 -0
  96. package/src/theme/PawprintProvider.tsx +26 -0
  97. package/src/theme/ThemeProvider.tsx +63 -0
  98. package/src/theme/index.ts +5 -0
  99. package/src/theme/theme.ts +3 -0
  100. package/src/theme/utils.ts +31 -0
  101. package/src/types/fonts.d.ts +4 -0
  102. package/src/types/index.ts +1 -0
  103. package/src/types/theme.ts +24 -0
  104. package/tsconfig.json +5 -0
  105. package/tsup.config.ts +11 -0
@@ -0,0 +1,200 @@
1
+ import React from "react"
2
+ import { View, StyleSheet, Text } from "react-native"
3
+ import { Icon } from "./Icon"
4
+ import type { IconProps, PawprintIcon, IconColour, IconSize } from "./Icon"
5
+ import * as core from "@butternutbox/pawprint-icons/core"
6
+ import * as marketing from "@butternutbox/pawprint-icons/marketing"
7
+ import * as payments from "@butternutbox/pawprint-icons/payments"
8
+ import * as flags from "@butternutbox/pawprint-icons/flags"
9
+
10
+ type IconCategory = "core" | "marketing" | "payments" | "flags"
11
+
12
+ function isComponent(value: unknown): value is PawprintIcon {
13
+ if (typeof value === "function") return true
14
+ if (value && typeof value === "object" && "$$typeof" in value) return true
15
+ return false
16
+ }
17
+
18
+ const categorised: Record<IconCategory, Record<string, unknown>> = {
19
+ core,
20
+ marketing,
21
+ payments,
22
+ flags
23
+ }
24
+
25
+ const iconMap: Record<string, PawprintIcon> = {}
26
+ for (const [category, mod] of Object.entries(categorised)) {
27
+ for (const [name, value] of Object.entries(mod)) {
28
+ if (isComponent(value)) {
29
+ iconMap[`${category}/${name}`] = value
30
+ }
31
+ }
32
+ }
33
+
34
+ const iconNames = Object.keys(iconMap)
35
+ const defaultIcon = iconNames[0]!
36
+
37
+ export default {
38
+ title: "Atoms/Icon",
39
+ component: Icon,
40
+ argTypes: {
41
+ size: {
42
+ control: { type: "select" },
43
+ options: ["xs", "sm", "md", "lg", "xl", "2xl"] as IconSize[],
44
+ description: "Size variant based on design tokens"
45
+ },
46
+ colour: {
47
+ control: { type: "select" },
48
+ options: [
49
+ "primary",
50
+ "secondary",
51
+ "disabled",
52
+ "success",
53
+ "warning",
54
+ "error",
55
+ "promo",
56
+ "info",
57
+ "alt",
58
+ "action-default",
59
+ "action-inverse"
60
+ ] as IconColour[],
61
+ description: "Semantic colour from icon tokens"
62
+ },
63
+ icon: {
64
+ control: { type: "select" },
65
+ options: iconNames,
66
+ mapping: iconMap,
67
+ description: "Icon component from pawprint-icons"
68
+ }
69
+ }
70
+ }
71
+
72
+ export const Default = (args: Omit<IconProps, "icon"> & { icon?: string }) => (
73
+ <Icon
74
+ icon={
75
+ (iconMap[args.icon ?? defaultIcon] ??
76
+ iconMap[defaultIcon]) as PawprintIcon
77
+ }
78
+ size={args.size}
79
+ colour={args.colour}
80
+ aria-label="Icon"
81
+ />
82
+ )
83
+ Default.args = {
84
+ icon: defaultIcon,
85
+ size: "md",
86
+ colour: "primary"
87
+ }
88
+
89
+ const sizes: IconSize[] = ["xs", "sm", "md", "lg", "xl", "2xl"]
90
+
91
+ export const AllSizes = () => {
92
+ const sampleIcon = iconMap[defaultIcon] as PawprintIcon
93
+ return (
94
+ <View style={styles.column}>
95
+ <Text style={styles.label}>Sizes</Text>
96
+ <View style={styles.row}>
97
+ {sizes.map((size) => (
98
+ <View key={size} style={styles.cell}>
99
+ <Icon icon={sampleIcon} size={size} colour="primary" />
100
+ <Text style={styles.sizeLabel}>{size}</Text>
101
+ </View>
102
+ ))}
103
+ </View>
104
+ </View>
105
+ )
106
+ }
107
+
108
+ const colours: IconColour[] = [
109
+ "primary",
110
+ "secondary",
111
+ "disabled",
112
+ "success",
113
+ "warning",
114
+ "error",
115
+ "promo",
116
+ "info",
117
+ "alt",
118
+ "action-default",
119
+ "action-inverse"
120
+ ]
121
+
122
+ export const AllColours = () => {
123
+ const sampleIcon = iconMap[defaultIcon] as PawprintIcon
124
+ return (
125
+ <View style={styles.column}>
126
+ <Text style={styles.label}>Colours</Text>
127
+ <View style={styles.row}>
128
+ {colours.map((colour) => (
129
+ <View key={colour} style={styles.cell}>
130
+ <Icon icon={sampleIcon} size="md" colour={colour} />
131
+ <Text style={styles.sizeLabel}>{colour}</Text>
132
+ </View>
133
+ ))}
134
+ </View>
135
+ </View>
136
+ )
137
+ }
138
+
139
+ export const AllIcons = () => (
140
+ <View style={styles.column}>
141
+ {Object.entries(categorised).map(([category, mod]) => (
142
+ <View key={category} style={styles.section}>
143
+ <Text style={styles.label}>{category}</Text>
144
+ <View style={styles.grid}>
145
+ {Object.entries(mod)
146
+ .filter(([, value]) => isComponent(value))
147
+ .map(([name, Component]) => (
148
+ <View key={name} style={styles.cell}>
149
+ <Icon
150
+ icon={Component as PawprintIcon}
151
+ size="md"
152
+ colour="primary"
153
+ />
154
+ <Text style={styles.sizeLabel} numberOfLines={1}>
155
+ {name}
156
+ </Text>
157
+ </View>
158
+ ))}
159
+ </View>
160
+ </View>
161
+ ))}
162
+ </View>
163
+ )
164
+
165
+ const styles = StyleSheet.create({
166
+ column: {
167
+ flexDirection: "column",
168
+ gap: 24
169
+ },
170
+ section: {
171
+ flexDirection: "column",
172
+ gap: 8
173
+ },
174
+ row: {
175
+ flexDirection: "row",
176
+ gap: 16,
177
+ alignItems: "center",
178
+ flexWrap: "wrap"
179
+ },
180
+ grid: {
181
+ flexDirection: "row",
182
+ gap: 12,
183
+ flexWrap: "wrap"
184
+ },
185
+ cell: {
186
+ alignItems: "center",
187
+ gap: 4,
188
+ minWidth: 48
189
+ },
190
+ label: {
191
+ fontSize: 13,
192
+ fontWeight: "600",
193
+ color: "#666"
194
+ },
195
+ sizeLabel: {
196
+ fontSize: 10,
197
+ color: "#999",
198
+ textAlign: "center"
199
+ }
200
+ })
@@ -0,0 +1,112 @@
1
+ import React from "react"
2
+ import { View, ViewProps } from "react-native"
3
+ import styled from "@emotion/native"
4
+ import { useTheme } from "@emotion/react"
5
+
6
+ type IconCategory = "core" | "marketing" | "payments" | "flags"
7
+ type IconSize = "xs" | "sm" | "md" | "lg" | "xl" | "2xl"
8
+ type IconColour =
9
+ | "primary"
10
+ | "secondary"
11
+ | "disabled"
12
+ | "success"
13
+ | "warning"
14
+ | "error"
15
+ | "promo"
16
+ | "info"
17
+ | "alt"
18
+ | "action-default"
19
+ | "action-inverse"
20
+
21
+ type PawprintIcon = React.ComponentType<{
22
+ width?: number
23
+ height?: number
24
+ color?: string
25
+ }> & {
26
+ category?: IconCategory
27
+ }
28
+
29
+ type IconOwnProps = {
30
+ icon: PawprintIcon
31
+ size?: IconSize
32
+ colour?: IconColour
33
+ "aria-label"?: string
34
+ }
35
+
36
+ type IconProps = IconOwnProps & Omit<ViewProps, keyof IconOwnProps>
37
+
38
+ const parseTokenValue = (value: string): number => parseFloat(value)
39
+
40
+ const StyledIconRoot = styled(View)<{
41
+ iconDimension: number
42
+ }>(({ iconDimension }) => ({
43
+ alignItems: "center",
44
+ justifyContent: "center",
45
+ flexShrink: 0,
46
+ width: iconDimension,
47
+ height: iconDimension
48
+ }))
49
+
50
+ /**
51
+ * Renders an SVG icon with token-based sizing and colour.
52
+ *
53
+ * @example
54
+ * ```tsx
55
+ * import { Icon } from "@butternutbox/pawprint-native"
56
+ * import { ArrowRight } from "@butternutbox/pawprint-icons/core"
57
+ *
58
+ * <Icon icon={ArrowRight} size="md" colour="primary" aria-label="Go forward" />
59
+ * ```
60
+ *
61
+ * @param icon - **(required)** Icon component from `@butternutbox/pawprint-icons/{category}`
62
+ * @param size - *(optional)* Size variant: xs, sm, md (default), lg, xl, 2xl
63
+ * @param colour - *(optional)* Colour variant from semantic icon tokens (default: primary)
64
+ * @param aria-label - *(optional)* Accessible label; omit for decorative icons
65
+ */
66
+ const Icon = React.forwardRef<View, IconProps>(
67
+ (
68
+ {
69
+ icon: IconComponent,
70
+ size = "md",
71
+ colour = "primary",
72
+ "aria-label": ariaLabel,
73
+ ...rest
74
+ },
75
+ ref
76
+ ) => {
77
+ const theme = useTheme()
78
+ const category = (IconComponent as PawprintIcon).category || "core"
79
+
80
+ const iconTokens = theme.tokens.semantics.colour.icon
81
+ let color: string
82
+ if (colour === "action-default") {
83
+ color = iconTokens.action.default
84
+ } else if (colour === "action-inverse") {
85
+ color = iconTokens.action.inverse
86
+ } else {
87
+ color = iconTokens[colour]
88
+ }
89
+
90
+ const sizing = theme.tokens.components.icons.sizing.icons
91
+ const categorySizing = sizing[category] as unknown as Record<string, string>
92
+ const dimension = parseTokenValue(categorySizing[size])
93
+
94
+ return (
95
+ <StyledIconRoot
96
+ ref={ref}
97
+ iconDimension={dimension}
98
+ accessibilityRole={ariaLabel ? "image" : undefined}
99
+ accessibilityLabel={ariaLabel}
100
+ accessible={!!ariaLabel}
101
+ {...rest}
102
+ >
103
+ <IconComponent width={dimension} height={dimension} color={color} />
104
+ </StyledIconRoot>
105
+ )
106
+ }
107
+ )
108
+
109
+ Icon.displayName = "Icon"
110
+
111
+ export { Icon }
112
+ export type { IconProps, PawprintIcon, IconCategory, IconSize, IconColour }
@@ -0,0 +1,8 @@
1
+ export { Icon } from "./Icon"
2
+ export type {
3
+ IconProps,
4
+ PawprintIcon,
5
+ IconCategory,
6
+ IconSize,
7
+ IconColour
8
+ } from "./Icon"
@@ -0,0 +1,162 @@
1
+ import React from "react"
2
+ import { View, StyleSheet } from "react-native"
3
+ import { IconButton } from "./IconButton"
4
+ import type { IconButtonProps } from "./IconButton"
5
+ import { Typography } from "../Typography"
6
+
7
+ const PlaceholderIcon = ({
8
+ width,
9
+ height,
10
+ color
11
+ }: {
12
+ width?: number
13
+ height?: number
14
+ color?: string
15
+ }) => (
16
+ <View
17
+ style={{
18
+ width: width || 24,
19
+ height: height || 24,
20
+ borderRadius: 4,
21
+ backgroundColor: color || "#000"
22
+ }}
23
+ />
24
+ )
25
+
26
+ export default {
27
+ title: "Atoms/IconButton",
28
+ component: IconButton,
29
+ argTypes: {
30
+ variant: {
31
+ control: { type: "select" },
32
+ options: ["filled", "outlined", "text"],
33
+ description: "Visual style variant"
34
+ },
35
+ size: {
36
+ control: { type: "select" },
37
+ options: ["sm", "md", "lg"],
38
+ description: "Size of the button"
39
+ },
40
+ colour: {
41
+ control: { type: "select" },
42
+ options: ["primary", "secondary", "tertiary"],
43
+ description: "Colour scheme"
44
+ },
45
+ loading: {
46
+ control: { type: "boolean" },
47
+ description: "Shows spinner and disables interaction"
48
+ },
49
+ disabled: {
50
+ control: { type: "boolean" },
51
+ description: "Prevents interaction"
52
+ }
53
+ }
54
+ }
55
+
56
+ export const Playground = (args: Partial<IconButtonProps>) => (
57
+ <IconButton
58
+ icon={PlaceholderIcon}
59
+ aria-label="Placeholder action"
60
+ {...args}
61
+ />
62
+ )
63
+ Playground.args = {
64
+ variant: "filled",
65
+ size: "md",
66
+ colour: "primary",
67
+ loading: false,
68
+ disabled: false
69
+ }
70
+
71
+ export const AllVariants = () => (
72
+ <View style={styles.column}>
73
+ {(["filled", "outlined", "text"] as const).map((variant) => (
74
+ <View key={variant} style={styles.section}>
75
+ <Typography size="sm" weight="semiBold" color="tertiary">
76
+ {variant}
77
+ </Typography>
78
+ <View style={styles.row}>
79
+ {(["primary", "secondary", "tertiary"] as const).map((colour) => (
80
+ <IconButton
81
+ key={colour}
82
+ icon={PlaceholderIcon}
83
+ variant={variant}
84
+ colour={colour}
85
+ aria-label={`${variant} ${colour}`}
86
+ />
87
+ ))}
88
+ </View>
89
+ </View>
90
+ ))}
91
+ </View>
92
+ )
93
+
94
+ export const AllSizes = () => (
95
+ <View style={styles.column}>
96
+ {(["filled", "outlined", "text"] as const).map((variant) => (
97
+ <View key={variant} style={styles.section}>
98
+ <Typography size="sm" weight="semiBold" color="tertiary">
99
+ {variant}
100
+ </Typography>
101
+ <View style={styles.row}>
102
+ {(["sm", "md", "lg"] as const).map((size) => (
103
+ <IconButton
104
+ key={size}
105
+ icon={PlaceholderIcon}
106
+ variant={variant}
107
+ size={size}
108
+ aria-label={`${variant} ${size}`}
109
+ />
110
+ ))}
111
+ </View>
112
+ </View>
113
+ ))}
114
+ </View>
115
+ )
116
+
117
+ export const AllStates = () => (
118
+ <View style={styles.column}>
119
+ {(["filled", "outlined", "text"] as const).map((variant) => (
120
+ <View key={variant} style={styles.section}>
121
+ <Typography size="sm" weight="semiBold" color="tertiary">
122
+ {variant}
123
+ </Typography>
124
+ <View style={styles.row}>
125
+ <IconButton
126
+ icon={PlaceholderIcon}
127
+ variant={variant}
128
+ aria-label={`${variant} default`}
129
+ />
130
+ <IconButton
131
+ icon={PlaceholderIcon}
132
+ variant={variant}
133
+ disabled
134
+ aria-label={`${variant} disabled`}
135
+ />
136
+ <IconButton
137
+ icon={PlaceholderIcon}
138
+ variant={variant}
139
+ loading
140
+ aria-label={`${variant} loading`}
141
+ />
142
+ </View>
143
+ </View>
144
+ ))}
145
+ </View>
146
+ )
147
+
148
+ const styles = StyleSheet.create({
149
+ column: {
150
+ flexDirection: "column",
151
+ gap: 24
152
+ },
153
+ section: {
154
+ flexDirection: "column",
155
+ gap: 8
156
+ },
157
+ row: {
158
+ flexDirection: "row",
159
+ gap: 12,
160
+ alignItems: "center"
161
+ }
162
+ })
@@ -0,0 +1,227 @@
1
+ import React from "react"
2
+ import { Pressable, View, PressableProps } from "react-native"
3
+ import styled from "@emotion/native"
4
+ import { useTheme } from "@emotion/react"
5
+ import { Spinner } from "../Spinner"
6
+ import type { PawprintIcon } from "../Icon"
7
+
8
+ type IconButtonVariant = "filled" | "outlined" | "text"
9
+ type IconButtonSize = "sm" | "md" | "lg"
10
+ type IconButtonColour = "primary" | "secondary" | "tertiary"
11
+
12
+ type IconButtonOwnProps = {
13
+ icon: PawprintIcon
14
+ variant?: IconButtonVariant
15
+ size?: IconButtonSize
16
+ colour?: IconButtonColour
17
+ loading?: boolean
18
+ disabled?: boolean
19
+ "aria-label": string
20
+ }
21
+
22
+ type IconButtonProps = IconButtonOwnProps &
23
+ Omit<PressableProps, keyof IconButtonOwnProps | "children">
24
+
25
+ const parseTokenValue = (value: string): number => parseFloat(value)
26
+
27
+ const sizeToIconSizeToken = {
28
+ lg: "xl",
29
+ md: "lg",
30
+ sm: "md"
31
+ } as const
32
+
33
+ const StyledIconButton = styled(Pressable)<{
34
+ buttonDimension: number
35
+ buttonBorderRadius: number
36
+ buttonBgColor: string
37
+ buttonOpacity: number
38
+ buttonBorderWidth?: number
39
+ buttonBorderColor?: string
40
+ }>(
41
+ ({
42
+ buttonDimension,
43
+ buttonBorderRadius,
44
+ buttonBgColor,
45
+ buttonOpacity,
46
+ buttonBorderWidth,
47
+ buttonBorderColor
48
+ }) => ({
49
+ alignItems: "center",
50
+ justifyContent: "center",
51
+ position: "relative",
52
+ width: buttonDimension,
53
+ height: buttonDimension,
54
+ borderRadius: buttonBorderRadius,
55
+ backgroundColor: buttonBgColor,
56
+ opacity: buttonOpacity,
57
+ ...(buttonBorderWidth
58
+ ? { borderWidth: buttonBorderWidth, borderColor: buttonBorderColor }
59
+ : {})
60
+ })
61
+ )
62
+
63
+ const StyledIconWrapper = styled(View)<{
64
+ iconDimension: number
65
+ iconOpacity: number
66
+ }>(({ iconDimension, iconOpacity }) => ({
67
+ width: iconDimension,
68
+ height: iconDimension,
69
+ opacity: iconOpacity
70
+ }))
71
+
72
+ const StyledSpinnerWrapper = styled(View)({
73
+ position: "absolute",
74
+ alignItems: "center",
75
+ justifyContent: "center"
76
+ })
77
+
78
+ /**
79
+ * A circular icon-only button with token-based styling.
80
+ *
81
+ * @example
82
+ * ```tsx
83
+ * import { IconButton } from "@butternutbox/pawprint-native"
84
+ * import { Add } from "@butternutbox/pawprint-icons/core"
85
+ *
86
+ * <IconButton icon={Add} aria-label="Add item" />
87
+ * <IconButton icon={Add} variant="outlined" aria-label="Add item" />
88
+ * ```
89
+ *
90
+ * @param icon - **(required)** Icon component
91
+ * @param variant - *(optional)* Visual variant: filled (default), outlined, or text.
92
+ * @param size - *(optional)* Size variant: sm, md (default), lg
93
+ * @param colour - *(optional)* Colour variant: primary (default), secondary, tertiary
94
+ * @param loading - *(optional)* Shows spinner and disables interaction
95
+ * @param aria-label - **(required)** Accessible label for the button
96
+ */
97
+ const IconButton = React.forwardRef<View, IconButtonProps>(
98
+ (
99
+ {
100
+ icon: IconComponent,
101
+ variant = "filled",
102
+ size = "md",
103
+ colour = "primary",
104
+ loading = false,
105
+ disabled,
106
+ "aria-label": ariaLabel,
107
+ ...rest
108
+ },
109
+ ref
110
+ ) => {
111
+ const theme = useTheme()
112
+ const isDisabled = disabled || loading
113
+ const buttons = theme.tokens.components.buttons
114
+ const sizeTokens = buttons.size[size]
115
+ const dimension = parseTokenValue(sizeTokens.height)
116
+ const iconSizing = theme.tokens.components.icons.sizing.icons.core
117
+ const iconDimension = parseTokenValue(iconSizing[sizeToIconSizeToken[size]])
118
+
119
+ const [pressed, setPressed] = React.useState(false)
120
+
121
+ const getVariantStyles = (
122
+ isPressed: boolean
123
+ ): {
124
+ backgroundColor: string
125
+ borderWidth?: number
126
+ borderColor?: string
127
+ iconColor: string
128
+ } => {
129
+ if (variant === "filled") {
130
+ const filled = buttons.iconButton.filledButton
131
+ const bgTokens = filled.colour.background[colour]
132
+ const iconTokens = filled.colour.icon[colour]
133
+ const hasStatefulIconColor =
134
+ "hover" in (iconTokens as unknown as Record<string, unknown>)
135
+ return {
136
+ backgroundColor: isDisabled
137
+ ? bgTokens.disabled
138
+ : loading || isPressed
139
+ ? bgTokens.selected
140
+ : bgTokens.default,
141
+ iconColor:
142
+ (loading || isPressed) && hasStatefulIconColor
143
+ ? (iconTokens as unknown as Record<string, string>).selected
144
+ : (iconTokens as unknown as Record<string, string>).default
145
+ }
146
+ }
147
+
148
+ if (variant === "outlined") {
149
+ const outline = buttons.iconButton.outlineButton
150
+ return {
151
+ backgroundColor:
152
+ loading || isPressed
153
+ ? outline.colour.background.selected
154
+ : "transparent",
155
+ borderWidth: parseTokenValue(outline.border.default),
156
+ borderColor: outline.colour.border.default,
157
+ iconColor:
158
+ loading || isPressed
159
+ ? outline.colour.icon.selected
160
+ : outline.colour.icon.default
161
+ }
162
+ }
163
+
164
+ // text variant
165
+ const text = buttons.iconButton.textButton
166
+ return {
167
+ backgroundColor:
168
+ loading || isPressed ? text.background.selected : "transparent",
169
+ iconColor: loading || isPressed ? text.icon.selected : text.icon.default
170
+ }
171
+ }
172
+
173
+ const variantStyles = getVariantStyles(pressed)
174
+
175
+ return (
176
+ <StyledIconButton
177
+ ref={ref}
178
+ disabled={isDisabled}
179
+ accessibilityLabel={ariaLabel}
180
+ accessibilityState={{ disabled: isDisabled, busy: loading }}
181
+ onPressIn={() => setPressed(true)}
182
+ onPressOut={() => setPressed(false)}
183
+ buttonDimension={dimension}
184
+ buttonBorderRadius={parseTokenValue(buttons.borderRadius.default)}
185
+ buttonBgColor={variantStyles.backgroundColor}
186
+ buttonOpacity={
187
+ isDisabled && !loading ? parseFloat(buttons.opacity.disabled) : 1
188
+ }
189
+ buttonBorderWidth={variantStyles.borderWidth}
190
+ buttonBorderColor={variantStyles.borderColor}
191
+ {...rest}
192
+ >
193
+ <StyledIconWrapper
194
+ iconDimension={iconDimension}
195
+ iconOpacity={loading ? 0 : 1}
196
+ >
197
+ <IconComponent
198
+ width={iconDimension}
199
+ height={iconDimension}
200
+ color={variantStyles.iconColor}
201
+ />
202
+ </StyledIconWrapper>
203
+ {loading && (
204
+ <StyledSpinnerWrapper>
205
+ <Spinner
206
+ size={size}
207
+ style={{
208
+ borderColor: "transparent",
209
+ borderTopColor: "currentColor"
210
+ }}
211
+ />
212
+ </StyledSpinnerWrapper>
213
+ )}
214
+ </StyledIconButton>
215
+ )
216
+ }
217
+ )
218
+
219
+ IconButton.displayName = "IconButton"
220
+
221
+ export { IconButton }
222
+ export type {
223
+ IconButtonProps,
224
+ IconButtonVariant,
225
+ IconButtonSize,
226
+ IconButtonColour
227
+ }