@butternutbox/pawprint-native 0.0.1 → 0.1.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 (182) hide show
  1. package/.turbo/turbo-build.log +15 -15
  2. package/CHANGELOG.md +16 -0
  3. package/COMPONENT_GUIDELINES.md +111 -4
  4. package/dist/index.cjs +12370 -1455
  5. package/dist/index.cjs.map +1 -1
  6. package/dist/index.d.cts +1110 -11
  7. package/dist/index.d.ts +1110 -11
  8. package/dist/index.js +12324 -1455
  9. package/dist/index.js.map +1 -1
  10. package/package.json +28 -9
  11. package/src/__mocks__/asset-stub.ts +1 -0
  12. package/src/__mocks__/emotion-native.tsx +18 -0
  13. package/src/__mocks__/react-native-gesture-handler.tsx +41 -0
  14. package/src/__mocks__/react-native-reanimated.tsx +79 -0
  15. package/src/__mocks__/react-native-safe-area-context.tsx +6 -0
  16. package/src/__mocks__/react-native-svg.tsx +27 -0
  17. package/src/__mocks__/react-native-worklets.tsx +11 -0
  18. package/src/__mocks__/react-native.tsx +338 -0
  19. package/src/__mocks__/rn-primitives/avatar.tsx +24 -0
  20. package/src/__mocks__/rn-primitives/checkbox.tsx +19 -0
  21. package/src/__mocks__/rn-primitives/select.tsx +116 -0
  22. package/src/__mocks__/rn-primitives/slider.tsx +40 -0
  23. package/src/__mocks__/rn-primitives/slot.tsx +30 -0
  24. package/src/__mocks__/rn-primitives/switch.tsx +24 -0
  25. package/src/__mocks__/rn-primitives/toggle.tsx +16 -0
  26. package/src/components/atoms/Avatar/Avatar.stories.tsx +57 -49
  27. package/src/components/atoms/Avatar/Avatar.test.tsx +269 -0
  28. package/src/components/atoms/Avatar/Avatar.tsx +68 -22
  29. package/src/components/atoms/Avatar/index.ts +1 -6
  30. package/src/components/atoms/Badge/Badge.stories.tsx +5 -29
  31. package/src/components/atoms/Badge/Badge.test.tsx +90 -0
  32. package/src/components/atoms/Button/Button.test.tsx +123 -0
  33. package/src/components/atoms/Button/Button.tsx +1 -1
  34. package/src/components/atoms/CarouselControls/CarouselControls.stories.tsx +217 -0
  35. package/src/components/atoms/CarouselControls/CarouselControls.tsx +127 -0
  36. package/src/components/atoms/CarouselControls/index.ts +2 -0
  37. package/src/components/atoms/Hint/Hint.test.tsx +36 -0
  38. package/src/components/atoms/Icon/Icon.test.tsx +98 -0
  39. package/src/components/atoms/Icon/Icon.tsx +5 -1
  40. package/src/components/atoms/IconButton/IconButton.test.tsx +101 -0
  41. package/src/components/atoms/Illustration/Illustration.test.tsx +55 -0
  42. package/src/components/atoms/Input/Input.stories.tsx +129 -86
  43. package/src/components/atoms/Input/Input.test.tsx +306 -0
  44. package/src/components/atoms/Input/Input.tsx +9 -1
  45. package/src/components/atoms/Input/InputField.tsx +226 -74
  46. package/src/components/atoms/Link/Link.test.tsx +89 -0
  47. package/src/components/atoms/Logo/Logo.registry.ts +30 -5
  48. package/src/components/atoms/Logo/Logo.stories.tsx +108 -0
  49. package/src/components/atoms/Logo/Logo.test.tsx +56 -0
  50. package/src/components/atoms/Logo/assets/BCorp.tsx +113 -0
  51. package/src/components/atoms/Logo/assets/ButternutFavicon.tsx +33 -0
  52. package/src/components/atoms/Logo/assets/ButternutPrimary.tsx +294 -0
  53. package/src/components/atoms/Logo/assets/ButternutTabbedBottom.tsx +294 -0
  54. package/src/components/atoms/Logo/assets/ButternutTabbedTop.tsx +294 -0
  55. package/src/components/atoms/Logo/assets/ButternutWordmark.tsx +274 -0
  56. package/src/components/atoms/Logo/assets/PsiBufetFavicon.tsx +45 -0
  57. package/src/components/atoms/Logo/assets/PsiBufetPrimary.tsx +218 -0
  58. package/src/components/atoms/Logo/assets/PsiBufetTabbedBottom.tsx +218 -0
  59. package/src/components/atoms/Logo/assets/PsiBufetTabbedTop.tsx +218 -0
  60. package/src/components/atoms/Logo/assets/PsiBufetWordmark.tsx +195 -0
  61. package/src/components/atoms/Logo/assets/index.ts +11 -0
  62. package/src/components/atoms/NumberInput/NumberInput.stories.tsx +183 -0
  63. package/src/components/atoms/NumberInput/NumberInput.test.tsx +261 -0
  64. package/src/components/atoms/NumberInput/NumberInput.tsx +129 -0
  65. package/src/components/atoms/NumberInput/NumberInputField.tsx +77 -0
  66. package/src/components/atoms/NumberInput/index.ts +4 -0
  67. package/src/components/atoms/Spinner/Spinner.test.tsx +46 -0
  68. package/src/components/atoms/Spinner/Spinner.tsx +14 -5
  69. package/src/components/atoms/Switch/Switch.test.tsx +92 -0
  70. package/src/components/atoms/Switch/Switch.tsx +16 -13
  71. package/src/components/atoms/Tag/Tag.test.tsx +70 -0
  72. package/src/components/atoms/TextArea/TextArea.stories.tsx +303 -0
  73. package/src/components/atoms/TextArea/TextArea.test.tsx +416 -0
  74. package/src/components/atoms/TextArea/TextArea.tsx +171 -0
  75. package/src/components/atoms/TextArea/TextAreaField.tsx +304 -0
  76. package/src/components/atoms/TextArea/TextAreaLabel.tsx +103 -0
  77. package/src/components/atoms/TextArea/index.ts +6 -0
  78. package/src/components/atoms/Typography/Typography.test.tsx +94 -0
  79. package/src/components/atoms/index.ts +3 -0
  80. package/src/components/molecules/Accordion/Accordion.stories.tsx +177 -0
  81. package/src/components/molecules/Accordion/Accordion.test.tsx +185 -0
  82. package/src/components/molecules/Accordion/Accordion.tsx +284 -0
  83. package/src/components/molecules/Accordion/index.ts +6 -0
  84. package/src/components/molecules/Animated/Animated.stories.tsx +254 -0
  85. package/src/components/molecules/Animated/Animated.tsx +283 -0
  86. package/src/components/molecules/Animated/index.ts +10 -0
  87. package/src/components/molecules/ButtonDock/ButtonDock.test.tsx +83 -0
  88. package/src/components/molecules/ButtonGroup/ButtonGroup.stories.tsx +8 -14
  89. package/src/components/molecules/ButtonGroup/ButtonGroup.test.tsx +73 -0
  90. package/src/components/molecules/ButtonGroup/ButtonGroup.tsx +25 -3
  91. package/src/components/molecules/Checkbox/Checkbox.stories.tsx +72 -0
  92. package/src/components/molecules/Checkbox/Checkbox.test.tsx +117 -0
  93. package/src/components/molecules/Checkbox/Checkbox.tsx +101 -95
  94. package/src/components/molecules/CopyField/CopyField.stories.tsx +313 -0
  95. package/src/components/molecules/CopyField/CopyField.test.tsx +431 -0
  96. package/src/components/molecules/CopyField/CopyField.tsx +156 -0
  97. package/src/components/molecules/CopyField/CopyFieldInput.tsx +127 -0
  98. package/src/components/molecules/CopyField/hooks/index.ts +1 -0
  99. package/src/components/molecules/CopyField/hooks/useCopyField.ts +25 -0
  100. package/src/components/molecules/CopyField/index.ts +4 -0
  101. package/src/components/molecules/DatePicker/DatePicker.stories.tsx +298 -0
  102. package/src/components/molecules/DatePicker/DatePicker.test.tsx +201 -0
  103. package/src/components/molecules/DatePicker/DatePicker.tsx +590 -0
  104. package/src/components/molecules/DatePicker/index.ts +2 -0
  105. package/src/components/molecules/Drawer/Drawer.stories.tsx +285 -0
  106. package/src/components/molecules/Drawer/Drawer.test.tsx +180 -0
  107. package/src/components/molecules/Drawer/Drawer.tsx +187 -0
  108. package/src/components/molecules/Drawer/DrawerBody.tsx +80 -0
  109. package/src/components/molecules/Drawer/DrawerClose.tsx +76 -0
  110. package/src/components/molecules/Drawer/DrawerContent.tsx +339 -0
  111. package/src/components/molecules/Drawer/DrawerContext.ts +19 -0
  112. package/src/components/molecules/Drawer/DrawerDescription.tsx +31 -0
  113. package/src/components/molecules/Drawer/DrawerDragContext.ts +11 -0
  114. package/src/components/molecules/Drawer/DrawerFooter.tsx +49 -0
  115. package/src/components/molecules/Drawer/DrawerFooterContext.ts +6 -0
  116. package/src/components/molecules/Drawer/DrawerGrabber.tsx +62 -0
  117. package/src/components/molecules/Drawer/DrawerHeader.tsx +244 -0
  118. package/src/components/molecules/Drawer/DrawerHeaderContext.ts +13 -0
  119. package/src/components/molecules/Drawer/DrawerOverlay.tsx +53 -0
  120. package/src/components/molecules/Drawer/DrawerTitle.tsx +32 -0
  121. package/src/components/molecules/Drawer/index.ts +12 -0
  122. package/src/components/molecules/FilterTab/FilterTab.stories.tsx +210 -0
  123. package/src/components/molecules/FilterTab/FilterTab.tsx +310 -0
  124. package/src/components/molecules/FilterTab/index.ts +2 -0
  125. package/src/components/molecules/MessageCard/MessageCard.stories.tsx +169 -0
  126. package/src/components/molecules/MessageCard/MessageCard.tsx +362 -0
  127. package/src/components/molecules/MessageCard/index.ts +10 -0
  128. package/src/components/molecules/Notification/Notification.stories.tsx +219 -0
  129. package/src/components/molecules/Notification/Notification.tsx +426 -0
  130. package/src/components/molecules/Notification/index.ts +2 -0
  131. package/src/components/molecules/NumberField/NumberField.stories.tsx +231 -0
  132. package/src/components/molecules/NumberField/NumberField.tsx +186 -0
  133. package/src/components/molecules/NumberField/NumberFieldInput.tsx +287 -0
  134. package/src/components/molecules/NumberField/index.ts +2 -0
  135. package/src/components/molecules/PasswordField/PasswordField.stories.tsx +362 -0
  136. package/src/components/molecules/PasswordField/PasswordField.test.tsx +369 -0
  137. package/src/components/molecules/PasswordField/PasswordField.tsx +194 -0
  138. package/src/components/molecules/PasswordField/PasswordFieldError.tsx +52 -0
  139. package/src/components/molecules/PasswordField/PasswordFieldInput.tsx +73 -0
  140. package/src/components/molecules/PasswordField/PasswordFieldRequirements.tsx +92 -0
  141. package/src/components/molecules/PasswordField/hooks/index.ts +2 -0
  142. package/src/components/molecules/PasswordField/hooks/usePasswordField.ts +113 -0
  143. package/src/components/molecules/PasswordField/index.ts +10 -0
  144. package/src/components/molecules/PictureSelector/PictureSelector.stories.tsx +243 -0
  145. package/src/components/molecules/PictureSelector/PictureSelector.tsx +313 -0
  146. package/src/components/molecules/PictureSelector/index.ts +5 -0
  147. package/src/components/molecules/Progress/Progress.stories.tsx +145 -0
  148. package/src/components/molecules/Progress/Progress.tsx +184 -0
  149. package/src/components/molecules/Progress/index.ts +2 -0
  150. package/src/components/molecules/Radio/Radio.test.tsx +104 -0
  151. package/src/components/molecules/Radio/Radio.tsx +1 -2
  152. package/src/components/molecules/SearchField/SearchField.stories.tsx +242 -0
  153. package/src/components/molecules/SearchField/SearchField.test.tsx +318 -0
  154. package/src/components/molecules/SearchField/SearchField.tsx +143 -0
  155. package/src/components/molecules/SearchField/SearchFieldInput.tsx +63 -0
  156. package/src/components/molecules/SearchField/hooks/index.ts +1 -0
  157. package/src/components/molecules/SearchField/hooks/useSearchField.ts +56 -0
  158. package/src/components/molecules/SearchField/index.ts +4 -0
  159. package/src/components/molecules/SegmentedControl/SegmentedControl.stories.tsx +31 -8
  160. package/src/components/molecules/SegmentedControl/SegmentedControl.test.tsx +141 -0
  161. package/src/components/molecules/SegmentedControl/SegmentedControl.tsx +237 -23
  162. package/src/components/molecules/SelectField/SelectField.stories.tsx +320 -0
  163. package/src/components/molecules/SelectField/SelectField.test.tsx +254 -0
  164. package/src/components/molecules/SelectField/SelectField.tsx +236 -0
  165. package/src/components/molecules/SelectField/SelectFieldContent.tsx +85 -0
  166. package/src/components/molecules/SelectField/SelectFieldItem.tsx +133 -0
  167. package/src/components/molecules/SelectField/SelectFieldTrigger.tsx +170 -0
  168. package/src/components/molecules/SelectField/SelectFieldValue.tsx +31 -0
  169. package/src/components/molecules/SelectField/hooks/index.ts +2 -0
  170. package/src/components/molecules/SelectField/hooks/useSelectField.ts +84 -0
  171. package/src/components/molecules/SelectField/index.ts +10 -0
  172. package/src/components/molecules/Slider/Slider.test.tsx +102 -0
  173. package/src/components/molecules/Slider/Slider.tsx +293 -180
  174. package/src/components/molecules/Tooltip/Tooltip.stories.tsx +168 -0
  175. package/src/components/molecules/Tooltip/Tooltip.tsx +326 -0
  176. package/src/components/molecules/Tooltip/index.ts +2 -0
  177. package/src/components/molecules/index.ts +15 -0
  178. package/src/test-utils.tsx +20 -0
  179. package/tsconfig.json +1 -1
  180. package/tsup.config.ts +16 -2
  181. package/vitest.config.ts +114 -0
  182. package/vitest.setup.ts +16 -0
@@ -0,0 +1,185 @@
1
+ import React from "react"
2
+ import { screen, fireEvent } from "@testing-library/react"
3
+ import { describe, it, expect, vi } from "vitest"
4
+ import { renderWithTheme } from "../../../test-utils"
5
+ import { Accordion } from "./Accordion"
6
+
7
+ describe("Accordion", () => {
8
+ describe("rendering", () => {
9
+ it("renders item titles", () => {
10
+ renderWithTheme(
11
+ <Accordion>
12
+ <Accordion.Item title="Question one">Answer one</Accordion.Item>
13
+ <Accordion.Item title="Question two">Answer two</Accordion.Item>
14
+ </Accordion>
15
+ )
16
+
17
+ expect(screen.getByText("Question one")).toBeInTheDocument()
18
+ expect(screen.getByText("Question two")).toBeInTheDocument()
19
+ })
20
+
21
+ it("hides panel content when closed", () => {
22
+ renderWithTheme(
23
+ <Accordion>
24
+ <Accordion.Item title="Question">Hidden answer</Accordion.Item>
25
+ </Accordion>
26
+ )
27
+
28
+ expect(screen.queryByText("Hidden answer")).not.toBeInTheDocument()
29
+ })
30
+ })
31
+
32
+ describe("interaction", () => {
33
+ it("opens panel when trigger is pressed", () => {
34
+ renderWithTheme(
35
+ <Accordion>
36
+ <Accordion.Item title="Question">Revealed answer</Accordion.Item>
37
+ </Accordion>
38
+ )
39
+
40
+ fireEvent.click(screen.getByRole("button", { name: /Question/ }))
41
+ expect(screen.getByText("Revealed answer")).toBeInTheDocument()
42
+ })
43
+
44
+ it("closes panel when trigger is pressed again", () => {
45
+ renderWithTheme(
46
+ <Accordion>
47
+ <Accordion.Item title="Question">Answer</Accordion.Item>
48
+ </Accordion>
49
+ )
50
+
51
+ const trigger = screen.getByRole("button", { name: /Question/ })
52
+ fireEvent.click(trigger)
53
+ fireEvent.click(trigger)
54
+
55
+ expect(screen.queryByText("Answer")).not.toBeInTheDocument()
56
+ })
57
+
58
+ it("closes sibling when a new item opens (single mode)", () => {
59
+ renderWithTheme(
60
+ <Accordion>
61
+ <Accordion.Item title="First">First answer</Accordion.Item>
62
+ <Accordion.Item title="Second">Second answer</Accordion.Item>
63
+ </Accordion>
64
+ )
65
+
66
+ fireEvent.click(screen.getByRole("button", { name: /First/ }))
67
+ expect(screen.getByText("First answer")).toBeInTheDocument()
68
+
69
+ fireEvent.click(screen.getByRole("button", { name: /Second/ }))
70
+ expect(screen.getByText("Second answer")).toBeInTheDocument()
71
+ expect(screen.queryByText("First answer")).not.toBeInTheDocument()
72
+ })
73
+
74
+ it("allows multiple open items when multiple is true", () => {
75
+ renderWithTheme(
76
+ <Accordion multiple>
77
+ <Accordion.Item title="First">First answer</Accordion.Item>
78
+ <Accordion.Item title="Second">Second answer</Accordion.Item>
79
+ </Accordion>
80
+ )
81
+
82
+ fireEvent.click(screen.getByRole("button", { name: /First/ }))
83
+ fireEvent.click(screen.getByRole("button", { name: /Second/ }))
84
+
85
+ expect(screen.getByText("First answer")).toBeInTheDocument()
86
+ expect(screen.getByText("Second answer")).toBeInTheDocument()
87
+ })
88
+
89
+ it("calls onValueChange when an item opens", () => {
90
+ const onValueChange = vi.fn()
91
+
92
+ renderWithTheme(
93
+ <Accordion onValueChange={onValueChange}>
94
+ <Accordion.Item value="item-1" title="Question">
95
+ Answer
96
+ </Accordion.Item>
97
+ </Accordion>
98
+ )
99
+
100
+ fireEvent.click(screen.getByRole("button", { name: /Question/ }))
101
+ expect(onValueChange).toHaveBeenCalledWith(["item-1"])
102
+ })
103
+ })
104
+
105
+ describe("controlled state", () => {
106
+ it("opens items specified in value prop", () => {
107
+ renderWithTheme(
108
+ <Accordion value={["item-1"]}>
109
+ <Accordion.Item value="item-1" title="Question">
110
+ Controlled answer
111
+ </Accordion.Item>
112
+ </Accordion>
113
+ )
114
+
115
+ expect(screen.getByText("Controlled answer")).toBeInTheDocument()
116
+ })
117
+
118
+ it("opens items specified in defaultValue", () => {
119
+ renderWithTheme(
120
+ <Accordion defaultValue={["item-1"]}>
121
+ <Accordion.Item value="item-1" title="Question">
122
+ Default open answer
123
+ </Accordion.Item>
124
+ </Accordion>
125
+ )
126
+
127
+ expect(screen.getByText("Default open answer")).toBeInTheDocument()
128
+ })
129
+ })
130
+
131
+ describe("disabled state", () => {
132
+ it("prevents opening when item is disabled", () => {
133
+ renderWithTheme(
134
+ <Accordion>
135
+ <Accordion.Item title="Question" disabled>
136
+ Hidden answer
137
+ </Accordion.Item>
138
+ </Accordion>
139
+ )
140
+
141
+ fireEvent.click(screen.getByRole("button", { name: /Question/ }))
142
+ expect(screen.queryByText("Hidden answer")).not.toBeInTheDocument()
143
+ })
144
+
145
+ it("disables all items when root is disabled", () => {
146
+ renderWithTheme(
147
+ <Accordion disabled>
148
+ <Accordion.Item title="Question">Answer</Accordion.Item>
149
+ </Accordion>
150
+ )
151
+
152
+ fireEvent.click(screen.getByRole("button", { name: /Question/ }))
153
+ expect(screen.queryByText("Answer")).not.toBeInTheDocument()
154
+ })
155
+ })
156
+
157
+ describe("accessibility", () => {
158
+ it("renders trigger as a button", () => {
159
+ renderWithTheme(
160
+ <Accordion>
161
+ <Accordion.Item title="Question">Answer</Accordion.Item>
162
+ </Accordion>
163
+ )
164
+
165
+ expect(
166
+ screen.getByRole("button", { name: /Question/ })
167
+ ).toBeInTheDocument()
168
+ })
169
+
170
+ it("sets expanded state on trigger", () => {
171
+ renderWithTheme(
172
+ <Accordion defaultValue={["q"]}>
173
+ <Accordion.Item value="q" title="Question">
174
+ Answer
175
+ </Accordion.Item>
176
+ </Accordion>
177
+ )
178
+
179
+ expect(screen.getByRole("button", { name: /Question/ })).toHaveAttribute(
180
+ "aria-expanded",
181
+ "true"
182
+ )
183
+ })
184
+ })
185
+ })
@@ -0,0 +1,284 @@
1
+ import React from "react"
2
+ import { View, Pressable, ViewProps } from "react-native"
3
+ import styled from "@emotion/native"
4
+ import { useTheme } from "@emotion/react"
5
+ import {
6
+ AddCircle,
7
+ AddCircleOutline,
8
+ RemoveCircle,
9
+ RemoveCircleOutline
10
+ } from "@butternutbox/pawprint-icons/core"
11
+ import { Icon } from "../../atoms/Icon"
12
+ import { Typography } from "../../atoms/Typography"
13
+
14
+ // ─── Types ────────────────────────────────────────────────────────────────────
15
+
16
+ export type AccordionSize = "small" | "large"
17
+
18
+ type AccordionRootOwnProps = {
19
+ size?: AccordionSize
20
+ multiple?: boolean
21
+ defaultValue?: string[]
22
+ value?: string[]
23
+ onValueChange?: (value: string[]) => void
24
+ disabled?: boolean
25
+ children: React.ReactNode
26
+ }
27
+
28
+ export type AccordionProps = AccordionRootOwnProps &
29
+ Omit<ViewProps, keyof AccordionRootOwnProps>
30
+
31
+ type AccordionItemOwnProps = {
32
+ title: string
33
+ children: React.ReactNode
34
+ value?: string
35
+ disabled?: boolean
36
+ }
37
+
38
+ export type AccordionItemProps = AccordionItemOwnProps &
39
+ Omit<ViewProps, keyof AccordionItemOwnProps>
40
+
41
+ // ─── Context ──────────────────────────────────────────────────────────────────
42
+
43
+ type AccordionContextValue = {
44
+ openValues: string[]
45
+ toggle: (value: string) => void
46
+ disabled: boolean
47
+ size: AccordionSize
48
+ }
49
+
50
+ const AccordionContext = React.createContext<AccordionContextValue>({
51
+ openValues: [],
52
+ toggle: () => {},
53
+ disabled: false,
54
+ size: "small"
55
+ })
56
+
57
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
58
+
59
+ const parseTokenValue = (value: string): number => parseFloat(value)
60
+
61
+ // ─── Styled Components ────────────────────────────────────────────────────────
62
+
63
+ const StyledRoot = styled(View)<{ rootGap: number }>(({ rootGap }) => ({
64
+ width: "100%",
65
+ flexDirection: "column",
66
+ gap: rootGap
67
+ }))
68
+
69
+ const StyledItem = styled(View)({
70
+ flexDirection: "column"
71
+ })
72
+
73
+ const StyledTrigger = styled(Pressable)<{
74
+ triggerOpacity: number
75
+ }>(({ triggerOpacity }) => ({
76
+ flexDirection: "row",
77
+ alignItems: "center",
78
+ justifyContent: "space-between",
79
+ width: "100%",
80
+ opacity: triggerOpacity
81
+ }))
82
+
83
+ const StyledPanel = styled(View)<{ panelGap: number }>(({ panelGap }) => ({
84
+ marginTop: panelGap
85
+ }))
86
+
87
+ const StyledDivider = styled(View)<{
88
+ dividerColor: string
89
+ dividerMarginTop: number
90
+ }>(({ dividerColor, dividerMarginTop }) => ({
91
+ height: 1,
92
+ backgroundColor: dividerColor,
93
+ marginTop: dividerMarginTop
94
+ }))
95
+
96
+ // ─── AccordionItem ────────────────────────────────────────────────────────────
97
+
98
+ /**
99
+ * A single item within an `Accordion`. Renders a pressable header and
100
+ * collapsible panel content.
101
+ *
102
+ * @param {string} title - The header text shown at all times.
103
+ * @param {React.ReactNode} children - Content revealed when open.
104
+ * @param {string} [value] - Unique identifier for controlled state.
105
+ * @param {boolean} [disabled=false] - Prevents the item from being opened.
106
+ */
107
+ const AccordionItem = React.forwardRef<View, AccordionItemProps>(
108
+ ({ title, children, value, disabled: itemDisabled, ...rest }, ref) => {
109
+ const theme = useTheme()
110
+ const { accordion } = theme.tokens.components
111
+ const {
112
+ openValues,
113
+ toggle,
114
+ disabled: rootDisabled,
115
+ size
116
+ } = React.useContext(AccordionContext)
117
+
118
+ const itemValue = value ?? title
119
+ const isOpen = openValues.includes(itemValue)
120
+ const isDisabled = itemDisabled || rootDisabled
121
+ const titleToken =
122
+ size === "large"
123
+ ? accordion.typography.desktop.title
124
+ : accordion.typography.mobile.title
125
+
126
+ const handlePress = () => {
127
+ if (!isDisabled) {
128
+ toggle(itemValue)
129
+ }
130
+ }
131
+
132
+ return (
133
+ <StyledItem ref={ref} {...rest}>
134
+ <StyledTrigger
135
+ onPress={handlePress}
136
+ disabled={isDisabled}
137
+ accessibilityRole="button"
138
+ accessibilityState={{ expanded: isOpen, disabled: isDisabled }}
139
+ triggerOpacity={isDisabled ? 0.4 : 1}
140
+ >
141
+ {({ pressed, hovered }: { pressed: boolean; hovered?: boolean }) => {
142
+ const isFilled = pressed || hovered
143
+ const icon = isOpen
144
+ ? isFilled
145
+ ? RemoveCircle
146
+ : RemoveCircleOutline
147
+ : isFilled
148
+ ? AddCircle
149
+ : AddCircleOutline
150
+
151
+ return (
152
+ <>
153
+ <View style={{ flex: 1 }}>
154
+ <Typography
155
+ token={titleToken}
156
+ color={accordion.colour.text.title}
157
+ >
158
+ {title}
159
+ </Typography>
160
+ </View>
161
+ <Icon icon={icon} size="md" colour="primary" />
162
+ </>
163
+ )
164
+ }}
165
+ </StyledTrigger>
166
+
167
+ {isOpen && (
168
+ <StyledPanel
169
+ panelGap={parseTokenValue(accordion.spacing.content.gap)}
170
+ >
171
+ <Typography
172
+ token={accordion.typography.subText}
173
+ color={accordion.colour.text.subText}
174
+ >
175
+ {children}
176
+ </Typography>
177
+ </StyledPanel>
178
+ )}
179
+
180
+ <StyledDivider
181
+ dividerColor={theme.tokens.semantics.colour.border.default}
182
+ dividerMarginTop={parseTokenValue(accordion.spacing.gap)}
183
+ />
184
+ </StyledItem>
185
+ )
186
+ }
187
+ )
188
+
189
+ AccordionItem.displayName = "Accordion.Item"
190
+
191
+ // ─── Accordion (Root) ─────────────────────────────────────────────────────────
192
+
193
+ /**
194
+ * A vertically stacked list of items that expand or collapse to reveal
195
+ * associated content with full accessibility support.
196
+ *
197
+ * @example
198
+ * ```tsx
199
+ * <Accordion>
200
+ * <Accordion.Item title="What ingredients do you use?">
201
+ * We use 60% human-quality protein with fresh vegetables.
202
+ * </Accordion.Item>
203
+ * <Accordion.Item title="How is the food delivered?">
204
+ * Fresh-frozen and delivered straight to your door.
205
+ * </Accordion.Item>
206
+ * </Accordion>
207
+ * ```
208
+ *
209
+ * @param {"small" | "large"} [size="small"] - Title size variant.
210
+ * @param {boolean} [multiple=false] - Allow multiple items open at once.
211
+ * @param {string[]} [defaultValue] - Uncontrolled: items open by default.
212
+ * @param {string[]} [value] - Controlled open items (requires `onValueChange`).
213
+ * @param {(value: string[]) => void} [onValueChange] - Fires when open items change.
214
+ * @param {boolean} [disabled=false] - Disables all items.
215
+ * @param {React.ReactNode} children - One or more `Accordion.Item` elements.
216
+ */
217
+ const AccordionRoot = React.forwardRef<View, AccordionProps>(
218
+ (
219
+ {
220
+ children,
221
+ size = "small",
222
+ multiple = false,
223
+ defaultValue,
224
+ value: controlledValue,
225
+ onValueChange,
226
+ disabled = false,
227
+ ...rest
228
+ },
229
+ ref
230
+ ) => {
231
+ const theme = useTheme()
232
+ const { accordion } = theme.tokens.components
233
+
234
+ const isControlled = controlledValue !== undefined
235
+ const [internalValue, setInternalValue] = React.useState<string[]>(
236
+ defaultValue ?? []
237
+ )
238
+ const openValues = isControlled ? controlledValue : internalValue
239
+
240
+ const toggle = React.useCallback(
241
+ (itemValue: string) => {
242
+ const isOpen = openValues.includes(itemValue)
243
+ let next: string[]
244
+
245
+ if (isOpen) {
246
+ next = openValues.filter((v) => v !== itemValue)
247
+ } else if (multiple) {
248
+ next = [...openValues, itemValue]
249
+ } else {
250
+ next = [itemValue]
251
+ }
252
+
253
+ if (!isControlled) {
254
+ setInternalValue(next)
255
+ }
256
+ onValueChange?.(next)
257
+ },
258
+ [openValues, multiple, isControlled, onValueChange]
259
+ )
260
+
261
+ const ctx = React.useMemo(
262
+ () => ({ openValues, toggle, disabled, size }),
263
+ [openValues, toggle, disabled, size]
264
+ )
265
+
266
+ return (
267
+ <AccordionContext.Provider value={ctx}>
268
+ <StyledRoot
269
+ ref={ref}
270
+ rootGap={parseTokenValue(accordion.spacing.gap)}
271
+ {...rest}
272
+ >
273
+ {children}
274
+ </StyledRoot>
275
+ </AccordionContext.Provider>
276
+ )
277
+ }
278
+ )
279
+
280
+ AccordionRoot.displayName = "Accordion"
281
+
282
+ export const Accordion = Object.assign(AccordionRoot, {
283
+ Item: AccordionItem
284
+ })
@@ -0,0 +1,6 @@
1
+ export { Accordion } from "./Accordion"
2
+ export type {
3
+ AccordionProps,
4
+ AccordionItemProps,
5
+ AccordionSize
6
+ } from "./Accordion"