diff --git a/.github/workflows/deploy-demo.yml b/.github/workflows/deploy-demo.yml
new file mode 100644
index 0000000..28c4997
--- /dev/null
+++ b/.github/workflows/deploy-demo.yml
@@ -0,0 +1,44 @@
+name: Deploy Demo to GitHub Pages
+
+on:
+ push:
+ branches: [main]
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ pages: write
+ id-token: write
+
+concurrency:
+ group: pages
+ cancel-in-progress: true
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 20
+ cache: npm
+
+ - run: npm ci
+
+ - run: npx vite build --config demo/vite.config.ts
+
+ - uses: actions/upload-pages-artifact@v3
+ with:
+ path: demo-dist
+
+ deploy:
+ needs: build
+ runs-on: ubuntu-latest
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+ steps:
+ - id: deployment
+ uses: actions/deploy-pages@v4
diff --git a/README.md b/README.md
index 0707bdc..80c164d 100644
--- a/README.md
+++ b/README.md
@@ -13,31 +13,33 @@ and name are likely to change drastically before a major version release.
Each component are meant to be copied from this repo to your own project and customized to your liking. There are no
CLI tools to help. just copy and paste from github.
-| `form_props` helper | Component | [Vanilla] | [Mantine] | ? |
-| :-------------------------------------- | :--------------------- | :----------------- | :------------------- | :------------------- |
-| `f.text_field` | Checkbox | :heavy_check_mark: | :heavy_check_mark: | :white_large_square: |
-| `f.collection_check_boxes` | CollectionCheckboxes | :heavy_check_mark: | :heavy_check_mark: | :white_large_square: |
-| `f.collection_radio_buttons` | CollectionRadioButtons | :heavy_check_mark: | :heavy_check_mark: | :white_large_square: |
-| `f.color_field` | ColorField | :heavy_check_mark: | :heavy_check_mark: | :white_large_square: |
-| `f.date_field` | DateField | :heavy_check_mark: | :heavy_check_mark: | :white_large_square: |
-| `f.datetime_local_field` | DateTimeLocalField | :heavy_check_mark: | :heavy_check_mark: | :white_large_square: |
-| `f.email_field` | EmailField | :heavy_check_mark: | :heavy_check_mark: | :white_large_square: |
-| `f.month_field` | MonthField | :heavy_check_mark: | :heavy_check_mark: | :white_large_square: |
-| `f.number_field` | NumberField | :heavy_check_mark: | :heavy_check_mark: | :white_large_square: |
-| `f.password_field` | PasswordField | :heavy_check_mark: | :heavy_check_mark: | :white_large_square: |
-| `f.range_field` | RangeField | :heavy_check_mark: | :white_large_square: | :white_large_square: |
-| `f.search_field` | SearchField | :heavy_check_mark: | :heavy_check_mark: | :white_large_square: |
-| `f.select` (`multiple: true` supported) | Select | :heavy_check_mark: | :heavy_check_mark: | :white_large_square: |
-| `f.tel_field` | TelField | :heavy_check_mark: | :heavy_check_mark: | :white_large_square: |
-| `f.file_field` | FileField | :heavy_check_mark: | :heavy_check_mark: | :white_large_square: |
-| `f.text_field` | TextField | :heavy_check_mark: | :heavy_check_mark: | :white_large_square: |
-| `f.time_field` | TimeField | :heavy_check_mark: | :heavy_check_mark: | :white_large_square: |
-| `f.url_field` | UrlField | :heavy_check_mark: | :heavy_check_mark: | :white_large_square: |
-| `f.text_area` | TextArea | :heavy_check_mark: | :heavy_check_mark: | :white_large_square: |
-| `f.grouped_collection_select` | Select | :heavy_check_mark: | :heavy_check_mark: | :white_large_square: |
-| `f.weekday_select` | Select | :heavy_check_mark: | :heavy_check_mark: | :white_large_square: |
-| `f.time_zone_select` | Select | :heavy_check_mark: | :heavy_check_mark: | :white_large_square: |
-| `f.submit` | SubmitButton | :heavy_check_mark: | :heavy_check_mark: | :white_large_square: |
+> Legend: :heavy_check_mark: library component | :globe_with_meridians: native HTML input | :x: not supported
+
+| `form_props` helper | Component | [Vanilla] | [Ark UI] | [Chakra UI] | [React Spectrum] | [Mantine] | [HeroUI] | [MUI] | [React Aria] |
+| :-------------------------------------- | :--------------------- | :----------------- | :------------------- | :----------------- | :----------------- | :------------------- | :----------------- | :----------------- | :----------------- |
+| `f.text_field` | TextField | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: |
+| `f.email_field` | EmailField | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: |
+| `f.password_field` | PasswordField | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: |
+| `f.number_field` | NumberField | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: |
+| `f.date_field` | DateField | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: |
+| `f.datetime_local_field` | DateTimeLocalField | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: |
+| `f.time_field` | TimeField | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: |
+| `f.search_field` | SearchField | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: |
+| `f.tel_field` | TelField | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: |
+| `f.url_field` | UrlField | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: |
+| `f.color_field` | ColorField | :globe_with_meridians: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :globe_with_meridians: | :heavy_check_mark: |
+| `f.month_field` | MonthField | :globe_with_meridians: | :globe_with_meridians: | :globe_with_meridians: | :globe_with_meridians: | :heavy_check_mark: | :globe_with_meridians: | :globe_with_meridians: | :globe_with_meridians: |
+| `f.range_field` | RangeField | :globe_with_meridians: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: |
+| `f.file_field` | FileField | :globe_with_meridians: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :globe_with_meridians: | :globe_with_meridians: | :heavy_check_mark: |
+| `f.check_box` | Checkbox | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: |
+| `f.collection_check_boxes` | CollectionCheckboxes | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: |
+| `f.collection_radio_buttons` | CollectionRadioButtons | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: |
+| `f.select` (`multiple: true` supported) | Select | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: |
+| `f.text_area` | TextArea | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: |
+| `f.grouped_collection_select` | Select | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: |
+| `f.weekday_select` | Select | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: |
+| `f.time_zone_select` | Select | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: |
+| `f.submit` | SubmitButton | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: |
## Installation
@@ -138,14 +140,62 @@ const {form, extras, inputs} = newPostForm
Vanilla wrappers wrap around basic React HTML tags. If you want to build
wrappers of your own, you can start here and use other UI wrappers as reference.
+## Ark UI
+
+To use the Ark UI wrappers, add the following library before copying:
+
+```
+yarn add @ark-ui/react
+```
+
+## Chakra UI
+
+To use the Chakra UI wrappers, add the following library before copying:
+
+```
+yarn add @chakra-ui/react
+```
+
+## React Spectrum
+
+To use the React Spectrum S2 wrappers, add the following libraries before copying:
+
+```
+yarn add @react-spectrum/s2 @internationalized/date
+```
+
## Mantine
-To use the Mantine wrappers, add the following libraries to your libraries before copying
+To use the Mantine wrappers, add the following libraries before copying:
+
+```
+yarn add @mantine/core @mantine/dates
+```
+
+## HeroUI
+
+To use the HeroUI wrappers, add the following libraries before copying:
+
+```
+yarn add @heroui/react @internationalized/date
+```
+
+Requires Tailwind CSS v4 with `@heroui/styles`.
+
+## MUI
+
+To use the MUI wrappers, add the following library before copying:
+
+```
+yarn add @mui/material @emotion/react @emotion/styled
+```
+
+## React Aria
+
+To use the React Aria wrappers, add the following libraries before copying:
```
-yarn add dayjs
-yarn add @mantine/core
-yarn add @mantine/dates
+yarn add react-aria-components @internationalized/date
```
## Contributors
@@ -154,3 +204,11 @@ Thank you, [contributors]!
[contributors]: https://github.com/thoughtbot/candy_wrapper/graphs/contributors
[form_props]: https://github.com/thoughtbot/form_props
+[Vanilla]: wrappers/ts/vanilla
+[Ark UI]: wrappers/ts/ark/v5
+[Chakra UI]: wrappers/ts/chakra/v3
+[React Spectrum]: wrappers/ts/react-spectrum/s2
+[Mantine]: wrappers/ts/mantine/v9
+[HeroUI]: wrappers/ts/heroui/v3
+[MUI]: wrappers/ts/mui/v9
+[React Aria]: wrappers/ts/react-aria/v1
diff --git a/demo/frames/ark.css b/demo/frames/ark.css
new file mode 100644
index 0000000..7d0c641
--- /dev/null
+++ b/demo/frames/ark.css
@@ -0,0 +1,440 @@
+/* Minimal styles for Ark UI headless components */
+
+:root {
+ --ark-accent: #EB5E41;
+ --ark-accent-fg: #fff;
+ --ark-bg: #fff;
+ --ark-fg: #1a1a1a;
+ --ark-fg-muted: #71717a;
+ --ark-border: #d4d4d8;
+ --ark-border-focus: #EB5E41;
+ --ark-error: #dc2626;
+ --ark-bg-muted: #f4f4f5;
+ --ark-radius: 6px;
+ --ark-font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+}
+
+body {
+ font-family: var(--ark-font);
+ color: var(--ark-fg);
+}
+
+
+/* Field */
+[data-scope="field"][data-part="root"] {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+[data-scope="field"][data-part="label"] {
+ font-size: 14px;
+ font-weight: 500;
+}
+
+[data-scope="field"][data-part="input"],
+[data-scope="field"][data-part="textarea"] {
+ border: 1px solid var(--ark-border);
+ border-radius: var(--ark-radius);
+ padding: 8px 12px;
+ font-size: 14px;
+ outline: none;
+ width: 100%;
+ box-sizing: border-box;
+}
+
+[data-scope="field"][data-part="input"]:focus,
+[data-scope="field"][data-part="textarea"]:focus {
+ border-color: var(--ark-border-focus);
+ box-shadow: 0 0 0 1px var(--ark-border-focus);
+}
+
+[data-scope="field"][data-part="error-text"] {
+ color: var(--ark-error);
+ font-size: 13px;
+}
+
+/* Checkbox */
+[data-scope="checkbox"][data-part="root"] {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ cursor: pointer;
+}
+
+[data-scope="checkbox"][data-part="label"] {
+ font-size: 14px;
+ font-weight: 500;
+ user-select: none;
+}
+
+[data-scope="checkbox"][data-part="control"] {
+ width: 18px;
+ height: 18px;
+ border: 1px solid var(--ark-border);
+ border-radius: 4px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ font-size: 12px;
+ color: var(--ark-accent-fg);
+ transition: background 0.15s, border-color 0.15s;
+}
+
+[data-scope="checkbox"][data-part="control"][data-state="checked"] {
+ background: var(--ark-accent);
+ border-color: var(--ark-accent);
+}
+
+/* Radio Group */
+[data-scope="radio-group"][data-part="root"] {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+[data-scope="radio-group"][data-part="label"] {
+ font-size: 14px;
+ font-weight: 500;
+}
+
+[data-scope="radio-group"][data-part="item"] {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ cursor: pointer;
+}
+
+[data-scope="radio-group"][data-part="item-control"] {
+ width: 18px;
+ height: 18px;
+ border: 1px solid var(--ark-border);
+ border-radius: 50%;
+ flex-shrink: 0;
+ transition: background 0.15s, border-color 0.15s;
+}
+
+[data-scope="radio-group"][data-part="item-control"][data-state="checked"] {
+ background: var(--ark-accent);
+ border-color: var(--ark-accent);
+ outline: 3px solid var(--ark-bg);
+ outline-offset: -4px;
+}
+
+[data-scope="radio-group"][data-part="item-text"] {
+ font-size: 14px;
+ font-weight: 500;
+}
+
+/* Select */
+[data-scope="select"][data-part="root"] {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ width: 100%;
+}
+
+[data-scope="select"][data-part="label"] {
+ font-size: 14px;
+ font-weight: 500;
+}
+
+[data-scope="select"][data-part="trigger"] {
+ border: 1px solid var(--ark-border);
+ border-radius: var(--ark-radius);
+ padding: 8px 12px;
+ font-size: 14px;
+ cursor: pointer;
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ text-align: left;
+ background: var(--ark-bg);
+ width: 100%;
+}
+
+[data-scope="select"][data-part="trigger"]:focus {
+ border-color: var(--ark-border-focus);
+ box-shadow: 0 0 0 1px var(--ark-border-focus);
+ outline: none;
+}
+
+[data-scope="select"][data-part="content"] {
+ background: var(--ark-bg);
+ border: 1px solid var(--ark-border);
+ border-radius: var(--ark-radius);
+ box-shadow: 0 4px 12px rgba(0,0,0,0.1);
+ padding: 4px;
+ z-index: 1000;
+ display: flex;
+ flex-direction: column;
+}
+
+[data-scope="select"][data-part="content"][hidden] {
+ display: none;
+}
+
+[data-scope="select"][data-part="item"] {
+ padding: 6px 8px;
+ border-radius: 4px;
+ cursor: pointer;
+ font-size: 14px;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ text-align: left;
+}
+
+[data-scope="select"][data-part="item"][data-highlighted] {
+ background: var(--ark-bg-muted);
+}
+
+[data-scope="select"][data-part="item"][data-state="checked"] {
+ color: var(--ark-accent);
+}
+
+[data-scope="select"][data-part="item-text"] {
+ flex: 1;
+ text-align: left;
+}
+
+[data-scope="select"][data-part="item-indicator"] {
+ color: var(--ark-accent);
+ flex-shrink: 0;
+}
+
+/* Number Input */
+[data-scope="number-input"][data-part="root"] {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+[data-scope="number-input"][data-part="label"] {
+ font-size: 14px;
+ font-weight: 500;
+}
+
+[data-scope="number-input"][data-part="control"] {
+ display: flex;
+ border: 1px solid var(--ark-border);
+ border-radius: var(--ark-radius);
+ overflow: hidden;
+}
+
+[data-scope="number-input"][data-part="input"] {
+ border: none;
+ padding: 8px 12px;
+ font-size: 14px;
+ outline: none;
+ flex: 1;
+ min-width: 0;
+}
+
+[data-scope="number-input"][data-part="increment-trigger"],
+[data-scope="number-input"][data-part="decrement-trigger"] {
+ border: none;
+ border-left: 1px solid var(--ark-border);
+ background: var(--ark-bg-muted);
+ padding: 4px 10px;
+ cursor: pointer;
+ font-size: 14px;
+ color: var(--ark-fg-muted);
+}
+
+[data-scope="number-input"][data-part="increment-trigger"]:hover,
+[data-scope="number-input"][data-part="decrement-trigger"]:hover {
+ background: var(--ark-border);
+}
+
+/* Slider */
+[data-scope="slider"][data-part="root"] {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ width: 100%;
+}
+
+[data-scope="slider"][data-part="label"] {
+ font-size: 14px;
+ font-weight: 500;
+}
+
+[data-scope="slider"][data-part="value-text"] {
+ font-size: 13px;
+ color: var(--ark-fg-muted);
+}
+
+[data-scope="slider"][data-part="control"] {
+ position: relative;
+ display: flex;
+ align-items: center;
+ height: 20px;
+}
+
+[data-scope="slider"][data-part="track"] {
+ background: var(--ark-bg-muted);
+ border-radius: 999px;
+ height: 6px;
+ flex: 1;
+ overflow: hidden;
+}
+
+[data-scope="slider"][data-part="range"] {
+ background: var(--ark-accent);
+ height: 100%;
+}
+
+[data-scope="slider"][data-part="thumb"] {
+ width: 20px;
+ height: 20px;
+ border-radius: 50%;
+ background: var(--ark-bg);
+ border: 2px solid var(--ark-accent);
+ box-shadow: 0 1px 3px rgba(0,0,0,0.15);
+ outline: none;
+ cursor: grab;
+}
+
+[data-scope="slider"][data-part="thumb"]:active {
+ cursor: grabbing;
+}
+
+/* Color Picker */
+[data-scope="color-picker"][data-part="root"] {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+[data-scope="color-picker"][data-part="label"] {
+ font-size: 14px;
+ font-weight: 500;
+}
+
+[data-scope="color-picker"][data-part="control"] {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+}
+
+[data-scope="color-picker"][data-part="channel-input"] {
+ border: 1px solid var(--ark-border);
+ border-radius: var(--ark-radius);
+ padding: 8px 12px;
+ font-size: 14px;
+ outline: none;
+ width: 120px;
+}
+
+[data-scope="color-picker"][data-part="channel-input"]:focus {
+ border-color: var(--ark-border-focus);
+ box-shadow: 0 0 0 1px var(--ark-border-focus);
+}
+
+[data-scope="color-picker"][data-part="trigger"] {
+ border: 1px solid var(--ark-border);
+ border-radius: var(--ark-radius);
+ padding: 2px;
+ cursor: pointer;
+ background: none;
+}
+
+[data-scope="color-picker"][data-part="swatch"] {
+ width: 28px;
+ height: 28px;
+ border-radius: 4px;
+}
+
+[data-scope="color-picker"][data-part="content"] {
+ background: var(--ark-bg);
+ border: 1px solid var(--ark-border);
+ border-radius: 8px;
+ box-shadow: 0 4px 16px rgba(0,0,0,0.12);
+ padding: 12px;
+ z-index: 1000;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+[data-scope="color-picker"][data-part="content"][hidden] {
+ display: none;
+}
+
+[data-scope="color-picker"][data-part="area"] {
+ border-radius: var(--ark-radius);
+ height: 140px;
+ overflow: hidden;
+}
+
+[data-scope="color-picker"][data-part="area-background"] {
+ height: 100%;
+}
+
+[data-scope="color-picker"][data-part="area-thumb"] {
+ width: 16px;
+ height: 16px;
+ border-radius: 50%;
+ box-shadow: 0 0 0 2px white, 0 0 2px 1px black;
+}
+
+[data-scope="color-picker"][data-part="channel-slider"] {
+ border-radius: var(--ark-radius);
+}
+
+[data-scope="color-picker"][data-part="channel-slider-track"] {
+ height: 10px;
+ border-radius: var(--ark-radius);
+}
+
+[data-scope="color-picker"][data-part="channel-slider-thumb"] {
+ width: 14px;
+ height: 14px;
+ border-radius: 50%;
+ box-shadow: 0 0 0 2px white, 0 0 2px 1px black;
+ transform: translate(-50%, -50%);
+}
+
+/* File Upload */
+[data-scope="file-upload"][data-part="root"] {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+[data-scope="file-upload"][data-part="label"] {
+ font-size: 14px;
+ font-weight: 500;
+}
+
+[data-scope="file-upload"][data-part="trigger"] {
+ border: 1px solid var(--ark-border);
+ border-radius: var(--ark-radius);
+ padding: 8px 16px;
+ cursor: pointer;
+ background: var(--ark-bg);
+ font-size: 14px;
+ display: inline-block;
+}
+
+[data-scope="file-upload"][data-part="trigger"]:hover {
+ background: var(--ark-bg-muted);
+}
+
+/* Submit button */
+button[type="submit"] {
+ background: var(--ark-accent);
+ color: var(--ark-accent-fg);
+ border: none;
+ border-radius: var(--ark-radius);
+ padding: 8px 20px;
+ font-size: 14px;
+ font-weight: 600;
+ cursor: pointer;
+}
+
+button[type="submit"]:hover {
+ opacity: 0.9;
+}
diff --git a/demo/frames/ark.html b/demo/frames/ark.html
new file mode 100644
index 0000000..434cdb5
--- /dev/null
+++ b/demo/frames/ark.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Ark UI
+
+
+
+
+
+
diff --git a/demo/frames/ark.tsx b/demo/frames/ark.tsx
new file mode 100644
index 0000000..e06cc0a
--- /dev/null
+++ b/demo/frames/ark.tsx
@@ -0,0 +1,53 @@
+import './ark.css'
+import {
+ TextField,
+ EmailField,
+ PasswordField,
+ NumberField,
+ DateField,
+ DateTimeLocalField,
+ TimeField,
+ MonthField,
+ ColorField,
+ SearchField,
+ TelField,
+ UrlField,
+ RangeField,
+ Checkbox,
+ CollectionCheckboxes,
+ CollectionRadioButtons,
+ Select,
+ TextArea,
+ FileField,
+ SubmitButton,
+ ValidationContext,
+} from '../../wrappers/ts/ark/v5/index'
+import { renderFrame } from './renderFrame'
+
+renderFrame(
+ {
+ TextField,
+ EmailField,
+ PasswordField,
+ NumberField,
+ DateField,
+ DateTimeLocalField,
+ TimeField,
+ MonthField,
+ ColorField,
+ SearchField,
+ TelField,
+ UrlField,
+ RangeField,
+ Checkbox,
+ CollectionCheckboxes,
+ CollectionRadioButtons,
+ Select,
+ MultiSelect: Select,
+ TextArea,
+ FileField,
+ SubmitButton,
+ },
+ undefined,
+ ValidationContext
+)
diff --git a/demo/frames/chakra.html b/demo/frames/chakra.html
new file mode 100644
index 0000000..8317251
--- /dev/null
+++ b/demo/frames/chakra.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Chakra UI
+
+
+
+
+
+
diff --git a/demo/frames/chakra.tsx b/demo/frames/chakra.tsx
new file mode 100644
index 0000000..6cde671
--- /dev/null
+++ b/demo/frames/chakra.tsx
@@ -0,0 +1,58 @@
+import React from 'react'
+import { ChakraProvider, defaultSystem } from '@chakra-ui/react'
+import {
+ TextField,
+ EmailField,
+ PasswordField,
+ NumberField,
+ DateField,
+ DateTimeLocalField,
+ TimeField,
+ MonthField,
+ ColorField,
+ SearchField,
+ TelField,
+ UrlField,
+ RangeField,
+ Checkbox,
+ CollectionCheckboxes,
+ CollectionRadioButtons,
+ Select,
+ TextArea,
+ FileField,
+ SubmitButton,
+ ValidationContext,
+} from '../../wrappers/ts/chakra/v3/index'
+import { renderFrame } from './renderFrame'
+
+const Provider = ({ children }: { children: React.ReactNode }) => (
+ {children}
+)
+
+renderFrame(
+ {
+ TextField,
+ EmailField,
+ PasswordField,
+ NumberField,
+ DateField,
+ DateTimeLocalField,
+ TimeField,
+ MonthField,
+ ColorField,
+ SearchField,
+ TelField,
+ UrlField,
+ RangeField,
+ Checkbox,
+ CollectionCheckboxes,
+ CollectionRadioButtons,
+ Select,
+ MultiSelect: Select,
+ TextArea,
+ FileField,
+ SubmitButton,
+ },
+ Provider,
+ ValidationContext
+)
diff --git a/demo/frames/heroui.css b/demo/frames/heroui.css
new file mode 100644
index 0000000..ed35a33
--- /dev/null
+++ b/demo/frames/heroui.css
@@ -0,0 +1,2 @@
+@import "tailwindcss";
+@import "@heroui/styles";
diff --git a/demo/frames/heroui.html b/demo/frames/heroui.html
new file mode 100644
index 0000000..322e2a5
--- /dev/null
+++ b/demo/frames/heroui.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ HeroUI
+
+
+
+
+
+
diff --git a/demo/frames/heroui.tsx b/demo/frames/heroui.tsx
new file mode 100644
index 0000000..5e50507
--- /dev/null
+++ b/demo/frames/heroui.tsx
@@ -0,0 +1,53 @@
+import './heroui.css'
+import {
+ TextField,
+ EmailField,
+ PasswordField,
+ NumberField,
+ DateField,
+ DateTimeLocalField,
+ TimeField,
+ MonthField,
+ ColorField,
+ SearchField,
+ TelField,
+ UrlField,
+ RangeField,
+ Checkbox,
+ CollectionCheckboxes,
+ CollectionRadioButtons,
+ Select,
+ TextArea,
+ FileField,
+ SubmitButton,
+ ValidationContext,
+} from '../../wrappers/ts/heroui/v3/index'
+import { renderFrame } from './renderFrame'
+
+renderFrame(
+ {
+ TextField,
+ EmailField,
+ PasswordField,
+ NumberField,
+ DateField,
+ DateTimeLocalField,
+ TimeField,
+ MonthField,
+ ColorField,
+ SearchField,
+ TelField,
+ UrlField,
+ RangeField,
+ Checkbox,
+ CollectionCheckboxes,
+ CollectionRadioButtons,
+ Select,
+ MultiSelect: Select,
+ TextArea,
+ FileField,
+ SubmitButton,
+ },
+ undefined,
+ ValidationContext
+)
diff --git a/demo/frames/mantine.html b/demo/frames/mantine.html
new file mode 100644
index 0000000..bb955b9
--- /dev/null
+++ b/demo/frames/mantine.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Mantine
+
+
+
+
+
+
diff --git a/demo/frames/mantine.tsx b/demo/frames/mantine.tsx
new file mode 100644
index 0000000..8a939d6
--- /dev/null
+++ b/demo/frames/mantine.tsx
@@ -0,0 +1,60 @@
+import React from 'react'
+import { MantineProvider } from '@mantine/core'
+import '@mantine/core/styles.css'
+import '@mantine/dates/styles.css'
+import {
+ TextField,
+ EmailField,
+ PasswordField,
+ NumberField,
+ DateField,
+ DateTimeLocalField,
+ TimeField,
+ MonthField,
+ ColorField,
+ SearchField,
+ TelField,
+ UrlField,
+ RangeField,
+ Checkbox,
+ CollectionCheckboxes,
+ CollectionRadioButtons,
+ Select,
+ TextArea,
+ FileField,
+ SubmitButton,
+ ValidationContext,
+} from '../../wrappers/ts/mantine/v9/index'
+import { renderFrame } from './renderFrame'
+
+const Provider = ({ children }: { children: React.ReactNode }) => (
+ {children}
+)
+
+renderFrame(
+ {
+ TextField,
+ EmailField,
+ PasswordField,
+ NumberField,
+ DateField,
+ DateTimeLocalField,
+ TimeField,
+ MonthField,
+ ColorField,
+ SearchField,
+ TelField,
+ UrlField,
+ RangeField,
+ Checkbox,
+ CollectionCheckboxes,
+ CollectionRadioButtons,
+ Select,
+ MultiSelect: Select,
+ TextArea,
+ FileField,
+ SubmitButton,
+ },
+ Provider,
+ ValidationContext
+)
diff --git a/demo/frames/mui.html b/demo/frames/mui.html
new file mode 100644
index 0000000..36af31e
--- /dev/null
+++ b/demo/frames/mui.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ MUI
+
+
+
+
+
+
diff --git a/demo/frames/mui.tsx b/demo/frames/mui.tsx
new file mode 100644
index 0000000..00c9cba
--- /dev/null
+++ b/demo/frames/mui.tsx
@@ -0,0 +1,52 @@
+import {
+ TextFieldComponent as TextField,
+ EmailField,
+ PasswordField,
+ NumberField,
+ DateField,
+ DateTimeLocalField,
+ TimeField,
+ MonthField,
+ ColorField,
+ SearchField,
+ TelField,
+ UrlField,
+ RangeField,
+ Checkbox,
+ CollectionCheckboxes,
+ CollectionRadioButtons,
+ Select,
+ TextArea,
+ FileField,
+ SubmitButton,
+ ValidationContext,
+} from '../../wrappers/ts/mui/v9/index'
+import { renderFrame } from './renderFrame'
+
+renderFrame(
+ {
+ TextField,
+ EmailField,
+ PasswordField,
+ NumberField,
+ DateField,
+ DateTimeLocalField,
+ TimeField,
+ MonthField,
+ ColorField,
+ SearchField,
+ TelField,
+ UrlField,
+ RangeField,
+ Checkbox,
+ CollectionCheckboxes,
+ CollectionRadioButtons,
+ Select,
+ MultiSelect: Select,
+ TextArea,
+ FileField,
+ SubmitButton,
+ },
+ undefined,
+ ValidationContext
+)
diff --git a/demo/frames/react-aria.css b/demo/frames/react-aria.css
new file mode 100644
index 0000000..fa3148e
--- /dev/null
+++ b/demo/frames/react-aria.css
@@ -0,0 +1,122 @@
+/* Minimal styles for React Aria Components */
+
+.react-aria-Checkbox {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ cursor: pointer;
+ font-size: 14px;
+}
+
+.react-aria-Checkbox .checkbox {
+ width: 18px;
+ height: 18px;
+ border: 2px solid #71717a;
+ border-radius: 4px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: background 0.15s, border-color 0.15s;
+}
+
+.react-aria-Checkbox[data-selected] .checkbox {
+ background: #1a1a1a;
+ border-color: #1a1a1a;
+}
+
+.react-aria-Checkbox .checkbox svg {
+ width: 14px;
+ height: 14px;
+ fill: none;
+ stroke: white;
+ stroke-width: 3;
+ stroke-dasharray: 22;
+ stroke-dashoffset: 66;
+}
+
+.react-aria-Checkbox[data-selected] .checkbox svg {
+ stroke-dashoffset: 44;
+}
+
+.react-aria-Radio {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ cursor: pointer;
+ font-size: 14px;
+}
+
+.react-aria-Radio::before {
+ content: '';
+ display: block;
+ width: 18px;
+ height: 18px;
+ border: 2px solid #71717a;
+ border-radius: 50%;
+ box-sizing: border-box;
+ transition: background 0.15s, border-color 0.15s;
+}
+
+.react-aria-Radio[data-selected]::before {
+ border-color: #1a1a1a;
+ border-width: 6px;
+}
+
+.react-aria-RadioGroup {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+/* Slider */
+.react-aria-Slider {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ width: 100%;
+}
+
+.react-aria-Slider [slot="label"],
+.react-aria-Slider [slot="output"] {
+ font-size: 14px;
+}
+
+.react-aria-SliderTrack {
+ position: relative;
+ height: 20px;
+ width: 100%;
+}
+
+.react-aria-SliderTrack::before {
+ content: '';
+ display: block;
+ position: absolute;
+ top: 50%;
+ transform: translateY(-50%);
+ width: 100%;
+ height: 4px;
+ background: #d4d4d8;
+ border-radius: 999px;
+}
+
+.react-aria-SliderThumb {
+ width: 18px;
+ height: 18px;
+ border-radius: 50%;
+ background: #fff;
+ border: 2px solid #1a1a1a;
+ position: absolute;
+ top: 50%;
+ transform: translate(-50%, -50%);
+ cursor: grab;
+}
+
+.react-aria-SliderThumb:active {
+ cursor: grabbing;
+}
+
+.react-aria-SliderOutput {
+ font-size: 13px;
+ color: #71717a;
+ align-self: flex-end;
+}
diff --git a/demo/frames/react-aria.html b/demo/frames/react-aria.html
new file mode 100644
index 0000000..21b5961
--- /dev/null
+++ b/demo/frames/react-aria.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ React Aria
+
+
+
+
+
+
diff --git a/demo/frames/react-aria.tsx b/demo/frames/react-aria.tsx
new file mode 100644
index 0000000..d4be128
--- /dev/null
+++ b/demo/frames/react-aria.tsx
@@ -0,0 +1,53 @@
+import './react-aria.css'
+import {
+ TextField,
+ EmailField,
+ PasswordField,
+ NumberField,
+ DateField,
+ DateTimeLocalField,
+ TimeField,
+ MonthField,
+ ColorField,
+ SearchField,
+ TelField,
+ UrlField,
+ RangeField,
+ Checkbox,
+ CollectionCheckboxes,
+ CollectionRadioButtons,
+ Select,
+ TextArea,
+ FileField,
+ SubmitButton,
+ ValidationContext,
+} from '../../wrappers/ts/react-aria/v1/index'
+import { renderFrame } from './renderFrame'
+
+renderFrame(
+ {
+ TextField,
+ EmailField,
+ PasswordField,
+ NumberField,
+ DateField,
+ DateTimeLocalField,
+ TimeField,
+ MonthField,
+ ColorField,
+ SearchField,
+ TelField,
+ UrlField,
+ RangeField,
+ Checkbox,
+ CollectionCheckboxes,
+ CollectionRadioButtons,
+ Select,
+ MultiSelect: Select,
+ TextArea,
+ FileField,
+ SubmitButton,
+ },
+ undefined,
+ ValidationContext
+)
diff --git a/demo/frames/react-spectrum.html b/demo/frames/react-spectrum.html
new file mode 100644
index 0000000..f2702ea
--- /dev/null
+++ b/demo/frames/react-spectrum.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ React Spectrum
+
+
+
+
+
+
diff --git a/demo/frames/react-spectrum.tsx b/demo/frames/react-spectrum.tsx
new file mode 100644
index 0000000..1d971da
--- /dev/null
+++ b/demo/frames/react-spectrum.tsx
@@ -0,0 +1,52 @@
+import {
+ TextField,
+ EmailField,
+ PasswordField,
+ NumberField,
+ DateField,
+ DateTimeLocalField,
+ TimeField,
+ MonthField,
+ ColorField,
+ SearchField,
+ TelField,
+ UrlField,
+ RangeField,
+ Checkbox,
+ CollectionCheckboxes,
+ CollectionRadioButtons,
+ Select,
+ TextArea,
+ FileField,
+ SubmitButton,
+ ValidationContext,
+} from '../../wrappers/ts/react-spectrum/s2/index'
+import { renderFrame } from './renderFrame'
+
+renderFrame(
+ {
+ TextField,
+ EmailField,
+ PasswordField,
+ NumberField,
+ DateField,
+ DateTimeLocalField,
+ TimeField,
+ MonthField,
+ ColorField,
+ SearchField,
+ TelField,
+ UrlField,
+ RangeField,
+ Checkbox,
+ CollectionCheckboxes,
+ CollectionRadioButtons,
+ Select,
+ MultiSelect: Select,
+ TextArea,
+ FileField,
+ SubmitButton,
+ },
+ undefined,
+ ValidationContext
+)
diff --git a/demo/frames/renderFrame.tsx b/demo/frames/renderFrame.tsx
new file mode 100644
index 0000000..b0a0492
--- /dev/null
+++ b/demo/frames/renderFrame.tsx
@@ -0,0 +1,66 @@
+import React, { useState, useEffect } from 'react'
+import { createRoot } from 'react-dom/client'
+import { payloads, errorPayloads, type FieldType } from '../payloads'
+
+type ComponentMap = Partial>>
+
+export function renderFrame(
+ components: ComponentMap,
+ Provider?: React.ComponentType<{ children: React.ReactNode }>,
+ ValidationContext?: React.Context>
+) {
+ function App() {
+ const [field, setField] = useState('TextField')
+ const [showErrors, setShowErrors] = useState(false)
+
+ useEffect(() => {
+ function onMessage(e: MessageEvent) {
+ if (e.data?.type === 'SELECT_FIELD') {
+ setField(e.data.field as FieldType)
+ }
+ if (e.data?.type === 'TOGGLE_ERRORS') {
+ setShowErrors(e.data.enabled)
+ }
+ }
+ window.addEventListener('message', onMessage)
+ return () => window.removeEventListener('message', onMessage)
+ }, [])
+
+ const Component = components[field]
+ const props = payloads[field]
+ const errorData = showErrors ? errorPayloads[field] : undefined
+
+ if (!Component) {
+ return Not supported
+ }
+
+ const errorKey = errorData?.errorKey
+ const errors = errorData?.errors ?? {}
+
+ const rendered = (
+
+
+
+ )
+
+ if (ValidationContext) {
+ return (
+
+ {rendered}
+
+ )
+ }
+
+ return rendered
+ }
+
+ const root = createRoot(document.getElementById('root')!)
+ const content = Provider ? (
+
+
+
+ ) : (
+
+ )
+ root.render(content)
+}
diff --git a/demo/frames/vanilla.html b/demo/frames/vanilla.html
new file mode 100644
index 0000000..d5ca7c5
--- /dev/null
+++ b/demo/frames/vanilla.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Vanilla
+
+
+
+
+
+
diff --git a/demo/frames/vanilla.tsx b/demo/frames/vanilla.tsx
new file mode 100644
index 0000000..3eed184
--- /dev/null
+++ b/demo/frames/vanilla.tsx
@@ -0,0 +1,52 @@
+import {
+ TextField,
+ EmailField,
+ PasswordField,
+ NumberField,
+ DateField,
+ DateTimeLocalField,
+ TimeField,
+ MonthField,
+ ColorField,
+ SearchField,
+ TelField,
+ UrlField,
+ RangeField,
+ Checkbox,
+ CollectionCheckboxes,
+ CollectionRadioButtons,
+ Select,
+ TextArea,
+ FileField,
+ SubmitButton,
+ ValidationContext,
+} from '../../wrappers/ts/vanilla/index'
+import { renderFrame } from './renderFrame'
+
+renderFrame(
+ {
+ TextField,
+ EmailField,
+ PasswordField,
+ NumberField,
+ DateField,
+ DateTimeLocalField,
+ TimeField,
+ MonthField,
+ ColorField,
+ SearchField,
+ TelField,
+ UrlField,
+ RangeField,
+ Checkbox,
+ CollectionCheckboxes,
+ CollectionRadioButtons,
+ Select,
+ MultiSelect: Select,
+ TextArea,
+ FileField,
+ SubmitButton,
+ },
+ undefined,
+ ValidationContext
+)
diff --git a/demo/index.html b/demo/index.html
new file mode 100644
index 0000000..f24a401
--- /dev/null
+++ b/demo/index.html
@@ -0,0 +1,499 @@
+
+
+
+
+
+ candy_wrapper — Demo
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/demo/payloads.ts b/demo/payloads.ts
new file mode 100644
index 0000000..a6f7d3e
--- /dev/null
+++ b/demo/payloads.ts
@@ -0,0 +1,327 @@
+export const FIELD_TYPES = [
+ 'TextField',
+ 'EmailField',
+ 'PasswordField',
+ 'NumberField',
+ 'DateField',
+ 'DateTimeLocalField',
+ 'TimeField',
+ 'MonthField',
+ 'ColorField',
+ 'SearchField',
+ 'TelField',
+ 'UrlField',
+ 'RangeField',
+ 'Checkbox',
+ 'CollectionCheckboxes',
+ 'CollectionRadioButtons',
+ 'Select',
+ 'MultiSelect',
+ 'TextArea',
+ 'FileField',
+ 'SubmitButton',
+] as const
+
+export type FieldType = (typeof FIELD_TYPES)[number]
+
+export const errorPayloads: Partial }>> = {
+ TextField: { errorKey: 'name', errors: { name: 'Name is required' } },
+ EmailField: { errorKey: 'email', errors: { email: 'Invalid email address' } },
+ PasswordField: { errorKey: 'password', errors: { password: 'Password is too short' } },
+ NumberField: { errorKey: 'quantity', errors: { quantity: 'Must be between 0 and 100' } },
+ DateField: { errorKey: 'date', errors: { date: 'Date is required' } },
+ DateTimeLocalField: { errorKey: 'starts_at', errors: { starts_at: 'Start time is required' } },
+ TimeField: { errorKey: 'time', errors: { time: 'Invalid time' } },
+ MonthField: { errorKey: 'month', errors: { month: 'Month is required' } },
+ ColorField: { errorKey: 'primary_color', errors: { primary_color: 'Invalid color' } },
+ SearchField: { errorKey: 'search', errors: { search: 'No results found' } },
+ TelField: { errorKey: 'phone', errors: { phone: 'Invalid phone number' } },
+ UrlField: { errorKey: 'website', errors: { website: 'Invalid URL' } },
+ RangeField: { errorKey: 'volume', errors: { volume: 'Volume out of range' } },
+ Checkbox: { errorKey: 'terms', errors: { terms: 'You must accept the terms' } },
+ CollectionCheckboxes: { errorKey: 'roles', errors: { roles: 'Select at least one role' } },
+ CollectionRadioButtons: { errorKey: 'plan', errors: { plan: 'Please select a plan' } },
+ Select: { errorKey: 'country', errors: { country: 'Country is required' } },
+ MultiSelect: { errorKey: 'languages', errors: { languages: 'Select at least one language' } },
+ TextArea: { errorKey: 'bio', errors: { bio: 'Bio is too short' } },
+ FileField: { errorKey: 'avatar', errors: { avatar: 'File is required' } },
+}
+
+export const rubySource: Record = {
+ TextField: `f.text_field :name, placeholder: "Enter your name"`,
+ EmailField: `f.email_field :email, placeholder: "you@example.com"`,
+ PasswordField: `f.password_field :password, placeholder: "Enter password"`,
+ NumberField: `f.number_field :quantity, in: 0..100`,
+ DateField: `f.date_field :date, min: "2024-01-01", max: "2025-12-31"`,
+ DateTimeLocalField: `f.datetime_local_field :starts_at`,
+ TimeField: `f.time_field :time`,
+ MonthField: `f.month_field :month, min: "2024-01", max: "2025-12"`,
+ ColorField: `f.color_field :primary_color`,
+ SearchField: `f.search_field :q, placeholder: "Search..."`,
+ TelField: `f.tel_field :phone, placeholder: "+1 (555) 000-0000"`,
+ UrlField: `f.url_field :website, placeholder: "https://"`,
+ RangeField: `f.range_field :volume, in: 0..100`,
+ Checkbox: `f.check_box :terms`,
+ CollectionCheckboxes: `f.collection_check_boxes :roles, Role.all, :id, :name`,
+ CollectionRadioButtons: `f.collection_radio_buttons :plan, Plan.all, :id, :name`,
+ Select: `f.select :country, options_for_select(countries, "us")`,
+ MultiSelect: `f.select :languages, options_for_select(languages, ["en", "fr"]), {}, { multiple: true }`,
+ TextArea: `f.text_area :bio, rows: 4, placeholder: "Tell us about yourself..."`,
+ FileField: `f.file_field :avatar`,
+ SubmitButton: `f.submit "Save Changes"`,
+}
+
+export const tsUsage: Record = {
+ TextField: ``,
+ EmailField: ``,
+ PasswordField: ``,
+ NumberField: ``,
+ DateField: ``,
+ DateTimeLocalField: ``,
+ TimeField: ``,
+ MonthField: ``,
+ ColorField: ``,
+ SearchField: ``,
+ TelField: ``,
+ UrlField: ``,
+ RangeField: ``,
+ Checkbox: ``,
+ CollectionCheckboxes: ``,
+ CollectionRadioButtons: ``,
+ Select: ``,
+ MultiSelect: ``,
+ TextArea: ``,
+ FileField: ``,
+ SubmitButton: ``,
+}
+
+export const payloads: Record> = {
+ TextField: {
+ type: 'text',
+ defaultValue: 'Hello world',
+ name: 'user[name]',
+ id: 'user_name',
+ label: 'Full Name',
+ placeholder: 'Enter your name',
+ },
+ EmailField: {
+ type: 'email',
+ defaultValue: 'user@example.com',
+ name: 'user[email]',
+ id: 'user_email',
+ label: 'Email Address',
+ placeholder: 'you@example.com',
+ },
+ PasswordField: {
+ type: 'password',
+ defaultValue: '',
+ name: 'user[password]',
+ id: 'user_password',
+ label: 'Password',
+ placeholder: 'Enter password',
+ },
+ NumberField: {
+ type: 'number',
+ defaultValue: '42',
+ name: 'product[quantity]',
+ id: 'product_quantity',
+ label: 'Quantity',
+ min: 0,
+ max: 100,
+ },
+ DateField: {
+ type: 'date',
+ defaultValue: '2024-06-15',
+ name: 'event[date]',
+ id: 'event_date',
+ label: 'Event Date',
+ min: '2024-01-01',
+ max: '2025-12-31',
+ },
+ DateTimeLocalField: {
+ type: 'datetime-local',
+ defaultValue: '2024-06-15T14:30:00',
+ name: 'event[starts_at]',
+ id: 'event_starts_at',
+ label: 'Start Time',
+ },
+ TimeField: {
+ type: 'time',
+ defaultValue: '14:30:00',
+ name: 'schedule[time]',
+ id: 'schedule_time',
+ label: 'Preferred Time',
+ },
+ MonthField: {
+ type: 'month',
+ defaultValue: '2024-06',
+ name: 'report[month]',
+ id: 'report_month',
+ label: 'Report Month',
+ min: '2024-01',
+ max: '2025-12',
+ },
+ ColorField: {
+ type: 'color',
+ defaultValue: '#3b82f6',
+ name: 'theme[primary_color]',
+ id: 'theme_primary_color',
+ label: 'Primary Color',
+ },
+ SearchField: {
+ type: 'search',
+ defaultValue: '',
+ name: 'q',
+ id: 'search_query',
+ label: 'Search',
+ placeholder: 'Search...',
+ },
+ TelField: {
+ type: 'tel',
+ defaultValue: '+1 (555) 123-4567',
+ name: 'user[phone]',
+ id: 'user_phone',
+ label: 'Phone Number',
+ placeholder: '+1 (555) 000-0000',
+ },
+ UrlField: {
+ type: 'url',
+ defaultValue: 'https://example.com',
+ name: 'user[website]',
+ id: 'user_website',
+ label: 'Website',
+ placeholder: 'https://',
+ },
+ RangeField: {
+ type: 'range',
+ defaultValue: '50',
+ name: 'settings[volume]',
+ id: 'settings_volume',
+ label: 'Volume',
+ min: 0,
+ max: 100,
+ },
+ Checkbox: {
+ type: 'checkbox',
+ defaultChecked: true,
+ name: 'user[terms]',
+ id: 'user_terms',
+ label: 'I agree to the terms',
+ includeHidden: true,
+ uncheckedValue: '0',
+ checkedValue: '1',
+ },
+ CollectionCheckboxes: {
+ includeHidden: true,
+ collection: [
+ {
+ type: 'checkbox',
+ name: 'user[roles][]',
+ id: 'user_roles_admin',
+ defaultChecked: true,
+ label: 'Admin',
+ uncheckedValue: '0',
+ value: 'admin',
+ },
+ {
+ type: 'checkbox',
+ name: 'user[roles][]',
+ id: 'user_roles_editor',
+ defaultChecked: false,
+ label: 'Editor',
+ uncheckedValue: '0',
+ value: 'editor',
+ },
+ {
+ type: 'checkbox',
+ name: 'user[roles][]',
+ id: 'user_roles_viewer',
+ defaultChecked: true,
+ label: 'Viewer',
+ uncheckedValue: '0',
+ value: 'viewer',
+ },
+ ],
+ },
+ CollectionRadioButtons: {
+ includeHidden: true,
+ collection: [
+ {
+ type: 'radio',
+ name: 'user[plan]',
+ id: 'user_plan_free',
+ defaultChecked: false,
+ label: 'Free',
+ value: 'free',
+ },
+ {
+ type: 'radio',
+ name: 'user[plan]',
+ id: 'user_plan_pro',
+ defaultChecked: true,
+ label: 'Pro',
+ value: 'pro',
+ },
+ {
+ type: 'radio',
+ name: 'user[plan]',
+ id: 'user_plan_enterprise',
+ defaultChecked: false,
+ label: 'Enterprise',
+ value: 'enterprise',
+ },
+ ],
+ },
+ Select: {
+ type: 'select',
+ name: 'user[country]',
+ id: 'user_country',
+ label: 'Country',
+ multiple: false,
+ options: [
+ { value: '', label: 'Select a country', disabled: false },
+ { value: 'us', label: 'United States', disabled: false },
+ { value: 'ca', label: 'Canada', disabled: false },
+ { value: 'uk', label: 'United Kingdom', disabled: false },
+ { value: 'de', label: 'Germany', disabled: false },
+ { value: 'jp', label: 'Japan', disabled: false },
+ ],
+ defaultValue: 'us',
+ },
+ MultiSelect: {
+ type: 'select',
+ name: 'user[languages][]',
+ id: 'user_languages',
+ label: 'Languages',
+ multiple: true,
+ options: [
+ { value: 'en', label: 'English', disabled: false },
+ { value: 'es', label: 'Spanish', disabled: false },
+ { value: 'fr', label: 'French', disabled: false },
+ { value: 'de', label: 'German', disabled: false },
+ { value: 'ja', label: 'Japanese', disabled: false },
+ ],
+ defaultValue: ['en', 'fr'],
+ },
+ TextArea: {
+ type: 'textarea',
+ defaultValue: 'This is a sample bio.\nIt spans multiple lines.',
+ name: 'user[bio]',
+ id: 'user_bio',
+ label: 'Bio',
+ placeholder: 'Tell us about yourself...',
+ rows: 4,
+ cols: 40,
+ },
+ FileField: {
+ type: 'file',
+ name: 'user[avatar]',
+ id: 'user_avatar',
+ label: 'Avatar',
+ },
+ SubmitButton: {
+ type: 'submit',
+ name: 'commit',
+ text: 'Save Changes',
+ },
+}
diff --git a/demo/vite.config.ts b/demo/vite.config.ts
new file mode 100644
index 0000000..e89496f
--- /dev/null
+++ b/demo/vite.config.ts
@@ -0,0 +1,43 @@
+import react from '@vitejs/plugin-react'
+import path from 'path'
+import { defineConfig } from 'vite'
+
+export default defineConfig({
+ plugins: [react()],
+ css: {
+ postcss: {
+ plugins: [require('@tailwindcss/postcss')],
+ },
+ },
+ root: __dirname,
+ base: './',
+ build: {
+ outDir: path.resolve(__dirname, '../demo-dist'),
+ emptyOutDir: true,
+ rollupOptions: {
+ input: {
+ main: path.resolve(__dirname, 'index.html'),
+ vanilla: path.resolve(__dirname, 'frames/vanilla.html'),
+ ark: path.resolve(__dirname, 'frames/ark.html'),
+ chakra: path.resolve(__dirname, 'frames/chakra.html'),
+ mantine: path.resolve(__dirname, 'frames/mantine.html'),
+ heroui: path.resolve(__dirname, 'frames/heroui.html'),
+ mui: path.resolve(__dirname, 'frames/mui.html'),
+ 'react-aria': path.resolve(__dirname, 'frames/react-aria.html'),
+ 'react-spectrum': path.resolve(__dirname, 'frames/react-spectrum.html'),
+ },
+ },
+ },
+ resolve: {
+ alias: {
+ '@thoughtbot/candy_wrapper': path.resolve(__dirname, '../src/index'),
+ },
+ },
+ optimizeDeps: {
+ include: [
+ '@adobe/react-spectrum',
+ '@react-spectrum/provider',
+ '@react-spectrum/theme-default',
+ ],
+ },
+})
diff --git a/wrappers/js/ark/v5/index.jsx b/wrappers/js/ark/v5/index.jsx
new file mode 100644
index 0000000..6d91e18
--- /dev/null
+++ b/wrappers/js/ark/v5/index.jsx
@@ -0,0 +1,323 @@
+/**
+ * A set of [candy_wrappers](https://github.com/thoughtbot/candy_wrapper) around
+ * Ark UI v5 input components. It works with the output from
+ * [FormProps](https://github.com/thoughtbot/form_props).
+ *
+ * You modify these components to fit your design needs.
+ */
+import React, { createContext, useContext, useMemo } from 'react';
+import { Checkbox } from '@ark-ui/react/checkbox';
+import { RadioGroup } from '@ark-ui/react/radio-group';
+import { Select, createListCollection } from '@ark-ui/react/select';
+import { NumberInput } from '@ark-ui/react/number-input';
+import { Slider } from '@ark-ui/react/slider';
+import { ColorPicker, parseColor } from '@ark-ui/react/color-picker';
+import { Field } from '@ark-ui/react/field';
+import { FileUpload } from '@ark-ui/react/file-upload';
+import { Portal } from '@ark-ui/react/portal';
+export const ValidationContext = createContext({});
+export const useErrorMessage = (errorKey) => {
+ const errors = useContext(ValidationContext);
+ return useMemo(() => {
+ if (!errorKey) {
+ return null;
+ }
+ const validationError = errors[errorKey];
+ const hasErrors = errorKey && validationError;
+ if (!hasErrors) {
+ return null;
+ }
+ const errorMessages = Array.isArray(validationError)
+ ? validationError
+ : [validationError];
+ return errorMessages.join(' ');
+ }, [errors, errorKey]);
+};
+export const Extras = (hiddenInputAttributes) => {
+ const hiddenProps = Object.values(hiddenInputAttributes);
+ const hiddenInputs = hiddenProps.map((props) => ());
+ return <>{hiddenInputs}>;
+};
+export const Form = ({ extras, validationErrors = {}, children, ...props }) => {
+ return ();
+};
+const FieldWrapperWithError = ({ label, id, errorKey, children, }) => {
+ const errorMessage = useErrorMessage(errorKey);
+ return (
+ {label}
+ {children}
+ {errorMessage && {errorMessage}}
+ );
+};
+export const CheckboxComponent = ({ type: _type, includeHidden, uncheckedValue, errorKey, label, ...rest }) => {
+ const errorMessage = useErrorMessage(errorKey);
+ return (<>
+ {includeHidden && ()}
+
+
+ ✓
+
+ {label}
+
+
+ >);
+};
+export { CheckboxComponent as Checkbox };
+export const CollectionCheckboxes = ({ includeHidden, collection, label, errorKey, }) => {
+ const errorMessage = useErrorMessage(errorKey);
+ if (collection.length === 0) {
+ return null;
+ }
+ const { name } = collection[0];
+ const defaultItems = collection.filter((option) => !!option.defaultChecked);
+ const items = collection.filter((option) => !!option.checked);
+ const valueProps = {};
+ if (defaultItems.length > 0) {
+ valueProps.defaultValue = defaultItems.map((option) => option.value);
+ }
+ else if (items.length > 0) {
+ valueProps.value = items.map((option) => option.value);
+ }
+ return (<>
+ {includeHidden && ()}
+
+
+ {collection.map((option) => (
+
+ ✓
+
+ {option.label}
+
+ ))}
+
+ >);
+};
+export const CollectionRadioButtons = ({ includeHidden, collection, label, errorKey, }) => {
+ const errorMessage = useErrorMessage(errorKey);
+ if (collection.length === 0) {
+ return null;
+ }
+ const { name } = collection[0];
+ const defaultItem = collection.find((option) => !!option.defaultChecked);
+ const item = collection.find((option) => !!option.checked);
+ const valueProps = {};
+ if (defaultItem) {
+ valueProps.defaultValue = defaultItem.value;
+ }
+ else if (item) {
+ valueProps.value = item.value;
+ }
+ return (<>
+ {includeHidden && ()}
+
+ {label}
+ {collection.map((option) => (
+
+ {option.label}
+
+ ))}
+
+ >);
+};
+export const TextField = ({ type: _type, label, errorKey, ...rest }) => {
+ return (
+
+ );
+};
+export const EmailField = ({ type: _type, label, errorKey, ...rest }) => {
+ return (
+
+ );
+};
+export const ColorField = ({ type: _type, label, errorKey, ...rest }) => {
+ const errorMessage = useErrorMessage(errorKey);
+ const { name, value, defaultValue, ...colorRest } = rest;
+ const colorValue = parseColor(value || defaultValue || '#000000');
+ return (
+
+ {label}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {errorMessage && {errorMessage}}
+ );
+};
+export const DateField = ({ type: _type, label, errorKey, value, defaultValue, ...rest }) => {
+ return (
+
+ );
+};
+export const DateTimeLocalField = ({ type: _type, label, errorKey, value, defaultValue, ...rest }) => {
+ return (
+
+ );
+};
+export const TimeField = ({ type: _type, label, errorKey, value, defaultValue, ...rest }) => {
+ return (
+
+ );
+};
+export const MonthField = ({ type: _type, label, errorKey, value, defaultValue, ...rest }) => {
+ return (
+
+ );
+};
+export const SearchField = ({ type: _type, label, errorKey, autosave: _autosave, results: _results, onsearch: _onsearch, incremental: _incremental, ...rest }) => {
+ return (
+
+ );
+};
+export const TelField = ({ type: _type, label, errorKey, ...rest }) => {
+ return (
+
+ );
+};
+export const UrlField = ({ type: _type, label, errorKey, ...rest }) => {
+ return (
+
+ );
+};
+export const PasswordField = ({ type: _type, label, errorKey, ...rest }) => {
+ return (
+
+ );
+};
+export const NumberField = ({ type: _type, label, errorKey, value, defaultValue, ...rest }) => {
+ const errorMessage = useErrorMessage(errorKey);
+ const numericDefault = defaultValue !== undefined && defaultValue !== ''
+ ? String(defaultValue)
+ : undefined;
+ const numericValue = value !== undefined && value !== '' ? String(value) : undefined;
+ return (
+
+ {label}
+
+
+ +
+ -
+
+
+ {errorMessage && {errorMessage}}
+ );
+};
+export const RangeField = ({ type: _type, label, errorKey, value, defaultValue, ...rest }) => {
+ const errorMessage = useErrorMessage(errorKey);
+ const numericDefault = defaultValue ? [Number(defaultValue)] : undefined;
+ const numericValue = value ? [Number(value)] : undefined;
+ return (
+
+ {label}
+
+
+
+
+
+
+
+
+
+
+ {errorMessage && {errorMessage}}
+ );
+};
+export const SelectComponent = ({ includeHidden, options, label, errorKey, multiple, type: _type, ...rest }) => {
+ const errorMessage = useErrorMessage(errorKey);
+ const addHidden = includeHidden && multiple;
+ const { name, id, value, defaultValue, ...selectRest } = rest;
+ const flatItems = options.flatMap((item) => {
+ if ('options' in item) {
+ return item.options;
+ }
+ return [item];
+ });
+ const collection = createListCollection({ items: flatItems });
+ const valueProps = {};
+ if (multiple) {
+ valueProps.multiple = true;
+ if (value && Array.isArray(value)) {
+ valueProps.value = value;
+ }
+ else if (defaultValue && Array.isArray(defaultValue)) {
+ valueProps.defaultValue = defaultValue;
+ }
+ }
+ else {
+ if (value) {
+ valueProps.value = Array.isArray(value) ? value : [value];
+ }
+ else if (defaultValue) {
+ valueProps.defaultValue = Array.isArray(defaultValue)
+ ? defaultValue
+ : [defaultValue];
+ }
+ }
+ return (
+ {addHidden && ()}
+
+ {label}
+
+
+
+
+
+
+
+
+ {flatItems.map((item) => (
+ {item.label}
+ ✓
+ ))}
+
+
+
+
+
+ {errorMessage && {errorMessage}}
+ );
+};
+export { SelectComponent as Select };
+export const TextArea = ({ type: _type, label, errorKey, ...rest }) => {
+ return (
+
+ );
+};
+export const FileField = ({ type: _type, label, errorKey, ...rest }) => {
+ const errorMessage = useErrorMessage(errorKey);
+ return (
+
+ {label}
+ Choose file
+
+
+ {errorMessage && {errorMessage}}
+ );
+};
+export const SubmitButton = ({ type: _type, text, ...rest }) => {
+ return ();
+};
diff --git a/wrappers/js/chakra/v3/components/ui/password-input.jsx b/wrappers/js/chakra/v3/components/ui/password-input.jsx
new file mode 100644
index 0000000..f101132
--- /dev/null
+++ b/wrappers/js/chakra/v3/components/ui/password-input.jsx
@@ -0,0 +1,52 @@
+"use client";
+import { Box, HStack, IconButton, Input, InputGroup, Stack, mergeRefs, useControllableState, } from "@chakra-ui/react";
+import * as React from "react";
+import { LuEye, LuEyeOff } from "react-icons/lu";
+export const PasswordInput = React.forwardRef(function PasswordInput(props, ref) {
+ const { rootProps, defaultVisible, visible: visibleProp, onVisibleChange, visibilityIcon = { on: , off: }, ...rest } = props;
+ const [visible, setVisible] = useControllableState({
+ value: visibleProp,
+ defaultValue: defaultVisible || false,
+ onChange: onVisibleChange,
+ });
+ const inputRef = React.useRef(null);
+ return ( {
+ if (rest.disabled)
+ return;
+ if (e.button !== 0)
+ return;
+ e.preventDefault();
+ setVisible(!visible);
+ }}>
+ {visible ? visibilityIcon.off : visibilityIcon.on}
+ } {...rootProps}>
+
+ );
+});
+const VisibilityTrigger = React.forwardRef(function VisibilityTrigger(props, ref) {
+ return ();
+});
+export const PasswordStrengthMeter = React.forwardRef(function PasswordStrengthMeter(props, ref) {
+ const { max = 4, value, ...rest } = props;
+ const percent = (value / max) * 100;
+ const { label, colorPalette } = getColorPalette(percent);
+ return (
+
+ {Array.from({ length: max }).map((_, index) => ())}
+
+ {label && {label}}
+ );
+});
+function getColorPalette(percent) {
+ switch (true) {
+ case percent < 33:
+ return { label: "Low", colorPalette: "red" };
+ case percent < 66:
+ return { label: "Medium", colorPalette: "orange" };
+ default:
+ return { label: "High", colorPalette: "green" };
+ }
+}
diff --git a/wrappers/js/chakra/v3/index.jsx b/wrappers/js/chakra/v3/index.jsx
new file mode 100644
index 0000000..d2e64aa
--- /dev/null
+++ b/wrappers/js/chakra/v3/index.jsx
@@ -0,0 +1,326 @@
+import React, { createContext, useContext, useMemo } from 'react';
+import { Input as ChakraInput, Textarea as ChakraTextarea, FileUpload as ChakraFileUpload, ColorPicker as ChakraColorPicker, Checkbox as ChakraCheckbox, CheckboxGroup as ChakraCheckboxGroup, RadioGroup as ChakraRadioGroup, Select as ChakraSelect, Fieldset as ChakraFieldset, NumberInput as ChakraNumberInput, Slider as ChakraSlider, Field as ChakraField, Button as ChakraButton, Portal, createListCollection, parseColor, } from '@chakra-ui/react';
+import { PasswordInput as ChakraPasswordInput } from './components/ui/password-input';
+export const ValidationContext = createContext({});
+export const useErrorMessage = (errorKey) => {
+ const errors = useContext(ValidationContext);
+ return useMemo(() => {
+ if (!errorKey) {
+ return null;
+ }
+ const validationError = errors[errorKey];
+ const hasErrors = errorKey && validationError;
+ if (!hasErrors) {
+ return null;
+ }
+ const errorMessages = Array.isArray(validationError)
+ ? validationError
+ : [validationError];
+ return errorMessages.join(' ');
+ }, [errors, errorKey]);
+};
+export const Extras = (hiddenInputAttributes) => {
+ const hiddenProps = Object.values(hiddenInputAttributes);
+ const hiddenInputs = hiddenProps.map((props) => ());
+ return <>{hiddenInputs}>;
+};
+export const Form = ({ extras, validationErrors = {}, children, ...props }) => {
+ return ();
+};
+const ChakraFieldWrapper = ({ label, errorKey, id, children, }) => {
+ const errorMessage = useErrorMessage(errorKey);
+ return (
+ {label}
+ {children}
+ {errorMessage && ({errorMessage})}
+ );
+};
+export const Checkbox = ({ type: _type, includeHidden, uncheckedValue, errorKey, label, ...rest }) => {
+ const { name } = rest;
+ const errorMessage = useErrorMessage(errorKey);
+ return (
+ {includeHidden && ()}
+
+
+
+
+
+ {label}
+
+ {errorMessage && ({errorMessage})}
+ );
+};
+export const CollectionCheckboxes = ({ includeHidden, collection, label, errorKey, }) => {
+ const errorMessage = useErrorMessage(errorKey);
+ if (collection.length === 0) {
+ return null;
+ }
+ const { name } = collection[0];
+ const defaultItems = collection.filter((option) => !!option.defaultChecked);
+ const checkedItems = collection.filter((option) => !!option.checked);
+ const valueProps = {};
+ if (defaultItems.length > 0) {
+ valueProps.defaultValue = defaultItems.map((option) => option.value);
+ }
+ else if (checkedItems.length > 0) {
+ valueProps.value = checkedItems.map((option) => option.value);
+ }
+ return (
+ {includeHidden && ()}
+
+
+ {label}
+
+
+ {collection.map((option) => (
+
+
+
+
+ {option.label}
+ ))}
+
+
+ {errorMessage && ({errorMessage})}
+ );
+};
+export const CollectionRadioButtons = ({ includeHidden, collection, label, errorKey, }) => {
+ const errorMessage = useErrorMessage(errorKey);
+ if (collection.length === 0) {
+ return null;
+ }
+ const defaultItem = collection.find((option) => !!option.defaultChecked);
+ const item = collection.find((option) => !!option.checked);
+ const valueProps = {};
+ if (defaultItem) {
+ valueProps.defaultValue = defaultItem.value;
+ }
+ else if (item) {
+ valueProps.value = item.value;
+ }
+ const radioButtons = collection.map((options) => {
+ const { checked: _checked, defaultChecked: _defaultChecked, type: _type, label: itemLabel, ...rest } = options;
+ return (
+
+
+ {itemLabel}
+ );
+ });
+ const { name } = collection[0];
+ return (
+ {includeHidden && ()}
+
+
+ {label}
+
+
+ {radioButtons}
+
+
+ {errorMessage && ({errorMessage})}
+ );
+};
+export const TextField = ({ type: _type, label, errorKey, size: _size, ...rest }) => {
+ return (
+
+ );
+};
+export const EmailField = ({ type: _type, label, errorKey, size: _size, ...rest }) => {
+ return (
+
+ );
+};
+export const ColorField = ({ type: _type, label, errorKey, value, defaultValue, ...rest }) => {
+ const errorMessage = useErrorMessage(errorKey);
+ const colorValue = parseColor(value || defaultValue || '#000000');
+ return (
+
+
+ {label}
+
+
+
+
+
+
+
+
+
+
+
+
+ {errorMessage && ({errorMessage})}
+ );
+};
+export const DateField = ({ type: _type, label, errorKey, ...rest }) => {
+ return (
+
+ );
+};
+export const DateTimeLocalField = ({ type: _type, label, errorKey, ...rest }) => {
+ return (
+
+ );
+};
+export const SearchField = ({ type: _type, label, errorKey, size: _size, ...rest }) => {
+ return (
+
+ );
+};
+export const TelField = ({ type: _type, label, errorKey, size: _size, ...rest }) => {
+ return (
+
+ );
+};
+export const UrlField = ({ type: _type, label, errorKey, size: _size, ...rest }) => {
+ return (
+
+ );
+};
+export const MonthField = ({ type: _type, label, errorKey, ...rest }) => {
+ return (
+
+ );
+};
+export const TimeField = ({ type: _type, label, errorKey, ...rest }) => {
+ return (
+
+ );
+};
+export const NumberField = ({ type: _type, label, errorKey, ...rest }) => {
+ return (
+
+
+
+
+
+
+
+ );
+};
+export const RangeField = ({ type: _type, label, errorKey, defaultValue, value, ...rest }) => {
+ const errorMessage = useErrorMessage(errorKey);
+ const nextDefaultValue = defaultValue ? [Number(defaultValue)] : undefined;
+ const nextValue = value ? [Number(value)] : undefined;
+ return (
+
+ {label}
+
+
+
+
+
+
+
+ {errorMessage && ({errorMessage})}
+ );
+};
+export const PasswordField = ({ type: _type, label, errorKey, size: _size, ...rest }) => {
+ return (
+
+ );
+};
+export const Select = ({ includeHidden, name, id, options, label, errorKey, multiple, type: _type, defaultValue, value, ...rest }) => {
+ const errorMessage = useErrorMessage(errorKey);
+ const addHidden = includeHidden && multiple;
+ const nextOptions = options.slice();
+ const firstOption = nextOptions[0];
+ let placeholder = 'Select...';
+ if (firstOption && !('options' in firstOption) && firstOption.value === '') {
+ placeholder = firstOption.label;
+ nextOptions.shift();
+ }
+ const flatItems = nextOptions.flatMap((item) => {
+ if ('options' in item) {
+ return item.options;
+ }
+ return [item];
+ });
+ const collection = createListCollection({ items: flatItems });
+ const defaultValueArray = defaultValue
+ ? Array.isArray(defaultValue)
+ ? defaultValue
+ : [defaultValue]
+ : undefined;
+ const valueArray = value
+ ? Array.isArray(value)
+ ? value
+ : [value]
+ : undefined;
+ return (
+ {addHidden && ()}
+
+
+ {label}
+
+
+
+
+
+
+
+
+
+
+
+ {nextOptions.map((item) => {
+ if ('options' in item) {
+ return (
+
+ {item.label}
+
+ {item.options.map((opt) => (
+ {opt.label}
+
+ ))}
+ );
+ }
+ return (
+ {item.label}
+
+ );
+ })}
+
+
+
+
+ {errorMessage && ({errorMessage})}
+ );
+};
+export const TextArea = ({ type: _type, label, errorKey, ...rest }) => {
+ return (
+
+ );
+};
+export const FileField = ({ type: _type, label, errorKey, ...rest }) => {
+ const errorMessage = useErrorMessage(errorKey);
+ return (
+
+
+ {label}
+
+ Choose file
+
+
+
+ {({ acceptedFiles }) => acceptedFiles.map((file) => (
+
+
+
+ ))}
+
+
+
+ {errorMessage && ({errorMessage})}
+ );
+};
+export const SubmitButton = ({ type: _type, text, ...rest }) => {
+ return (
+ {text}
+ );
+};
diff --git a/wrappers/js/heroui/v3/index.jsx b/wrappers/js/heroui/v3/index.jsx
new file mode 100644
index 0000000..dfd7824
--- /dev/null
+++ b/wrappers/js/heroui/v3/index.jsx
@@ -0,0 +1,433 @@
+/**
+ * A set of [candy_wrappers](https://github.com/thoughtbot/candy_wrapper) around
+ * HeroUI v3 input components. It works with the output from
+ * [FormProps](https://github.com/thoughtbot/form_props).
+ *
+ * You modify these components to fit your design needs.
+ */
+import React, { createContext, useContext, useMemo, useState } from 'react';
+import { parseDate, parseDateTime, parseTime, } from '@internationalized/date';
+import { TextField as HeroTextField, Header as HeroHeader, Input as HeroInput, Label as HeroLabel, FieldError as HeroFieldError, TextArea as HeroTextArea, Checkbox as HeroCheckbox, CheckboxGroup as HeroCheckboxGroup, RadioGroup as HeroRadioGroup, Radio as HeroRadio, Select as HeroSelect, ListBox as HeroListBox, NumberField as HeroNumberField, SearchField as HeroSearchField, DateField as HeroDateField, TimeField as HeroTimeField, Slider as HeroSlider, Button as HeroButton, ColorField as HeroColorField, ColorSwatch as HeroColorSwatch, ErrorMessage as HeroErrorMessage, parseColor, } from '@heroui/react';
+export const ValidationContext = createContext({});
+export const useErrorMessage = (errorKey) => {
+ const errors = useContext(ValidationContext);
+ return useMemo(() => {
+ if (!errorKey) {
+ return null;
+ }
+ const validationError = errors[errorKey];
+ const hasErrors = errorKey && validationError;
+ if (!hasErrors) {
+ return null;
+ }
+ const errorMessages = Array.isArray(validationError)
+ ? validationError
+ : [validationError];
+ return errorMessages.join(' ');
+ }, [errors, errorKey]);
+};
+export const Extras = (hiddenInputAttributes) => {
+ const hiddenProps = Object.values(hiddenInputAttributes);
+ const hiddenInputs = hiddenProps.map((props) => ());
+ return <>{hiddenInputs}>;
+};
+export const Form = ({ extras, validationErrors = {}, children, ...props }) => {
+ return ();
+};
+// -- Transform functions --
+export const checkboxPropsToHeroProps = (props) => {
+ const { defaultChecked, checked, type: _type, ...rest } = props;
+ const heroProps = {
+ ...rest,
+ };
+ if (defaultChecked !== undefined) {
+ heroProps.defaultSelected = defaultChecked;
+ }
+ if (checked !== undefined) {
+ heroProps.isSelected = checked;
+ }
+ return heroProps;
+};
+export const textFieldToHeroProps = (props) => {
+ const { type: _type, placeholder, ...fieldProps } = props;
+ return { fieldProps, inputProps: { placeholder } };
+};
+export const numberFieldToHeroProps = (props) => {
+ const { value, defaultValue, min, max, type: _type, ...rest } = props;
+ const heroProps = { ...rest };
+ if (value !== undefined && value !== '') {
+ heroProps.value = Number(value);
+ }
+ if (defaultValue !== undefined && defaultValue !== '') {
+ heroProps.defaultValue = Number(defaultValue);
+ }
+ if (min !== undefined) {
+ heroProps.minValue = min;
+ }
+ if (max !== undefined) {
+ heroProps.maxValue = max;
+ }
+ return heroProps;
+};
+export const dateFieldToHeroProps = (props) => {
+ const { max, min, value, defaultValue, type: _type, ...rest } = props;
+ const heroProps = { ...rest };
+ if (max)
+ heroProps.maxValue = parseDate(max);
+ if (min)
+ heroProps.minValue = parseDate(min);
+ if (defaultValue)
+ heroProps.defaultValue = parseDate(defaultValue);
+ if (value)
+ heroProps.value = parseDate(value);
+ return heroProps;
+};
+export const dateTimeLocalFieldToHeroProps = (props) => {
+ const { max, min, value, defaultValue, type: _type, ...rest } = props;
+ const heroProps = { ...rest };
+ heroProps.granularity = 'second';
+ if (max)
+ heroProps.maxValue = parseDateTime(max);
+ if (min)
+ heroProps.minValue = parseDateTime(min);
+ if (defaultValue)
+ heroProps.defaultValue = parseDateTime(defaultValue);
+ if (value)
+ heroProps.value = parseDateTime(value);
+ return heroProps;
+};
+export const timeFieldToHeroProps = (props) => {
+ const { min, max, value, defaultValue, type: _type, ...rest } = props;
+ const heroProps = { ...rest };
+ if (value)
+ heroProps.value = parseTime(value);
+ if (defaultValue)
+ heroProps.defaultValue = parseTime(defaultValue);
+ if (min)
+ heroProps.minValue = parseTime(min);
+ if (max)
+ heroProps.maxValue = parseTime(max);
+ return heroProps;
+};
+export const Checkbox = ({ includeHidden, uncheckedValue, errorKey, label, ...rest }) => {
+ const heroProps = checkboxPropsToHeroProps(rest);
+ const { name } = heroProps;
+ const errorMessage = useErrorMessage(errorKey);
+ return (<>
+ {includeHidden && ()}
+
+
+
+
+
+ {label}
+
+
+ >);
+};
+export const CollectionCheckboxes = ({ includeHidden, collection, label, errorKey, }) => {
+ const errorMessage = useErrorMessage(errorKey);
+ if (collection.length === 0) {
+ return null;
+ }
+ const defaultItems = collection.filter((option) => !!option.defaultChecked);
+ const items = collection.filter((option) => !!option.checked);
+ const valueProps = {};
+ if (defaultItems.length > 0) {
+ valueProps.defaultValue = defaultItems.map((option) => option.value);
+ }
+ else if (items.length > 0) {
+ valueProps.value = items.map((option) => option.value);
+ }
+ const { name } = collection[0];
+ return (<>
+ {includeHidden && ()}
+
+ {label}
+ {collection.map((option) => (
+
+
+
+
+ {option.label}
+
+ ))}
+ {errorMessage && {errorMessage}}
+
+ >);
+};
+export const CollectionRadioButtons = ({ includeHidden, collection, label, errorKey, }) => {
+ const errorMessage = useErrorMessage(errorKey);
+ if (collection.length === 0) {
+ return null;
+ }
+ const defaultItem = collection.find((option) => !!option.defaultChecked);
+ const item = collection.find((option) => !!option.checked);
+ const valueProps = {};
+ if (defaultItem) {
+ valueProps.defaultValue = defaultItem.value;
+ }
+ else if (item) {
+ valueProps.value = item.value;
+ }
+ const { name } = collection[0];
+ return (<>
+ {includeHidden && ()}
+
+ {label}
+ {collection.map((option) => (
+
+
+
+
+ {option.label}
+
+ ))}
+ {errorMessage && {errorMessage}}
+
+ >);
+};
+export const TextField = ({ label, errorKey, ...rest }) => {
+ const { fieldProps, inputProps } = textFieldToHeroProps(rest);
+ const errorMessage = useErrorMessage(errorKey);
+ return (
+ {label}
+
+ {errorMessage && {errorMessage}}
+ );
+};
+export const EmailField = ({ label, errorKey, ...rest }) => {
+ const { fieldProps, inputProps } = textFieldToHeroProps(rest);
+ const errorMessage = useErrorMessage(errorKey);
+ return (
+ {label}
+
+ {errorMessage && {errorMessage}}
+ );
+};
+export const ColorField = ({ type: _type, label, errorKey, ...rest }) => {
+ const errorMessage = useErrorMessage(errorKey);
+ const initialColor = rest.value || rest.defaultValue;
+ const [color, setColor] = useState(initialColor ? parseColor(initialColor) : null);
+ return (
+ {label}
+
+
+
+
+
+
+ {errorMessage && {errorMessage}}
+ );
+};
+export const DateField = ({ label, errorKey, ...rest }) => {
+ const heroProps = dateFieldToHeroProps(rest);
+ const errorMessage = useErrorMessage(errorKey);
+ return (
+ {label}
+
+
+ {(segment) => }
+
+
+ {errorMessage && {errorMessage}}
+ );
+};
+export const DateTimeLocalField = ({ label, errorKey, ...rest }) => {
+ const heroProps = dateTimeLocalFieldToHeroProps(rest);
+ const errorMessage = useErrorMessage(errorKey);
+ return (
+ {label}
+
+
+ {(segment) => }
+
+
+ {errorMessage && {errorMessage}}
+ );
+};
+export const TimeField = ({ label, errorKey, ...rest }) => {
+ const heroProps = timeFieldToHeroProps(rest);
+ const errorMessage = useErrorMessage(errorKey);
+ return (
+ {label}
+
+
+ {(segment) => }
+
+
+ {errorMessage && {errorMessage}}
+ );
+};
+export const SearchField = ({ label, errorKey, autosave: _autosave, results: _results, onsearch: _onsearch, incremental: _incremental, ...rest }) => {
+ const { fieldProps, inputProps } = textFieldToHeroProps(rest);
+ const errorMessage = useErrorMessage(errorKey);
+ return (
+ {label}
+
+ {errorMessage && {errorMessage}}
+ );
+};
+export const TelField = ({ label, errorKey, ...rest }) => {
+ const { fieldProps, inputProps } = textFieldToHeroProps(rest);
+ const errorMessage = useErrorMessage(errorKey);
+ return (
+ {label}
+
+ {errorMessage && {errorMessage}}
+ );
+};
+export const UrlField = ({ label, errorKey, ...rest }) => {
+ const { fieldProps, inputProps } = textFieldToHeroProps(rest);
+ const errorMessage = useErrorMessage(errorKey);
+ return (
+ {label}
+
+ {errorMessage && {errorMessage}}
+ );
+};
+export const MonthField = ({ label, errorKey, type: _type, ...rest }) => {
+ const errorMessage = useErrorMessage(errorKey);
+ return (
+ {label}
+
+ {errorMessage && {errorMessage}}
+ );
+};
+export const NumberField = ({ label, errorKey, ...rest }) => {
+ const heroProps = numberFieldToHeroProps(rest);
+ const errorMessage = useErrorMessage(errorKey);
+ return (
+ {label}
+
+
+
+
+
+ {errorMessage && {errorMessage}}
+ );
+};
+export const RangeField = ({ label, errorKey, value, defaultValue, type: _type, min, max, ...rest }) => {
+ const errorMessage = useErrorMessage(errorKey);
+ const numericValue = value ? Number(value) : undefined;
+ const numericDefault = defaultValue ? Number(defaultValue) : undefined;
+ return (
+ {label}
+
+
+ {({ state }) => (<>
+
+ {state.values.map((_, i) => ())}
+ >)}
+
+ {errorMessage}
+ );
+};
+export const PasswordField = ({ label, errorKey, ...rest }) => {
+ const { fieldProps, inputProps } = textFieldToHeroProps(rest);
+ const errorMessage = useErrorMessage(errorKey);
+ return (
+ {label}
+
+ {errorMessage && {errorMessage}}
+ );
+};
+export const Select = ({ includeHidden, label, errorKey, multiple, type: _type, options, ...rest }) => {
+ const errorMessage = useErrorMessage(errorKey);
+ const { name, id } = rest;
+ const addHidden = includeHidden && multiple;
+ const selectedValue = 'value' in rest ? rest.value : undefined;
+ const selectedDefault = 'defaultValue' in rest ? rest.defaultValue : undefined;
+ const selectionProps = {};
+ if (multiple) {
+ selectionProps.selectionMode = 'multiple';
+ if (selectedValue) {
+ selectionProps.value = Array.isArray(selectedValue)
+ ? selectedValue
+ : [selectedValue];
+ }
+ else if (selectedDefault) {
+ selectionProps.defaultValue = Array.isArray(selectedDefault)
+ ? selectedDefault
+ : [selectedDefault];
+ }
+ }
+ else {
+ if (selectedValue) {
+ selectionProps.value = Array.isArray(selectedValue)
+ ? selectedValue[0]
+ : selectedValue;
+ }
+ else if (selectedDefault) {
+ selectionProps.defaultValue = Array.isArray(selectedDefault)
+ ? selectedDefault[0]
+ : selectedDefault;
+ }
+ }
+ const hasGroups = options.some((item) => 'options' in item);
+ return (<>
+ {addHidden && ()}
+
+ {label}
+
+
+
+
+
+
+ {hasGroups
+ ? options.map((item) => {
+ if ('options' in item) {
+ return (
+ {item.label}
+ {item.options.map((opt) => (
+ {opt.label}
+
+ ))}
+ );
+ }
+ return (
+ {item.label}
+
+ );
+ })
+ : options.map((item) => {
+ if ('options' in item)
+ return null;
+ return (
+ {item.label}
+
+ );
+ })}
+
+
+ {errorMessage && {errorMessage}}
+
+ >);
+};
+export const TextArea = ({ label, errorKey, type: _type, ...rest }) => {
+ const errorMessage = useErrorMessage(errorKey);
+ return (
+ {label}
+
+ {errorMessage && {errorMessage}}
+ );
+};
+export const FileField = ({ type: _type, label, errorKey, ...rest }) => {
+ const errorMessage = useErrorMessage(errorKey);
+ return (
+ {label}
+
+ {errorMessage && {errorMessage}}
+
);
+};
+export const SubmitButton = ({ type: _type, text, ...rest }) => {
+ return (
+ {text}
+ );
+};
diff --git a/wrappers/js/mui/v9/index.jsx b/wrappers/js/mui/v9/index.jsx
new file mode 100644
index 0000000..8fe4ecb
--- /dev/null
+++ b/wrappers/js/mui/v9/index.jsx
@@ -0,0 +1,238 @@
+import React, { createContext, useContext, useMemo, } from 'react';
+import TextField from '@mui/material/TextField';
+import MuiCheckbox from '@mui/material/Checkbox';
+import FormControlLabel from '@mui/material/FormControlLabel';
+import Radio from '@mui/material/Radio';
+import RadioGroup from '@mui/material/RadioGroup';
+import MuiSelect from '@mui/material/Select';
+import MenuItem from '@mui/material/MenuItem';
+import Slider from '@mui/material/Slider';
+import Button from '@mui/material/Button';
+import FormControl from '@mui/material/FormControl';
+import InputLabel from '@mui/material/InputLabel';
+import FormHelperText from '@mui/material/FormHelperText';
+import FormLabel from '@mui/material/FormLabel';
+export const ValidationContext = createContext({});
+export const useErrorMessage = (errorKey) => {
+ const errors = useContext(ValidationContext);
+ return useMemo(() => {
+ if (!errorKey) {
+ return null;
+ }
+ const validationError = errors[errorKey];
+ const hasErrors = errorKey && validationError;
+ if (!hasErrors) {
+ return null;
+ }
+ const errorMessages = Array.isArray(validationError)
+ ? validationError
+ : [validationError];
+ return errorMessages.join(' ');
+ }, [errors, errorKey]);
+};
+export const Extras = (hiddenInputAttributes) => {
+ const hiddenProps = Object.values(hiddenInputAttributes);
+ const hiddenInputs = hiddenProps.map((props) => ());
+ return <>{hiddenInputs}>;
+};
+export const Form = ({ extras, validationErrors = {}, children, ...props }) => {
+ return ();
+};
+export const TextFieldComponent = (props) => {
+ // Strip candy_wrapper-specific props
+ const { type, errorKey, label, ...rest } = props;
+ // Transform
+ const errorMessage = useErrorMessage(errorKey);
+ // Spread rest into MUI TextField
+ return ();
+};
+export { TextFieldComponent as TextField };
+export const EmailField = (props) => {
+ const { type, errorKey, label, ...rest } = props;
+ const errorMessage = useErrorMessage(errorKey);
+ return ();
+};
+export const ColorField = (props) => {
+ const { type, color, errorKey, label, ...rest } = props;
+ const errorMessage = useErrorMessage(errorKey);
+ return ();
+};
+export const DateField = (props) => {
+ const { type, errorKey, label, ...rest } = props;
+ const errorMessage = useErrorMessage(errorKey);
+ return ();
+};
+export const DateTimeLocalField = (props) => {
+ const { type, errorKey, label, ...rest } = props;
+ const errorMessage = useErrorMessage(errorKey);
+ return ();
+};
+export const SearchField = (props) => {
+ const { type, errorKey, label, ...rest } = props;
+ const errorMessage = useErrorMessage(errorKey);
+ return ();
+};
+export const TelField = (props) => {
+ const { type, errorKey, label, ...rest } = props;
+ const errorMessage = useErrorMessage(errorKey);
+ return ();
+};
+export const UrlField = (props) => {
+ const { type, errorKey, label, ...rest } = props;
+ const errorMessage = useErrorMessage(errorKey);
+ return ();
+};
+export const MonthField = (props) => {
+ const { type, errorKey, label, ...rest } = props;
+ const errorMessage = useErrorMessage(errorKey);
+ return ();
+};
+export const TimeField = (props) => {
+ const { type, errorKey, label, ...rest } = props;
+ const errorMessage = useErrorMessage(errorKey);
+ return ();
+};
+export const NumberField = (props) => {
+ const { type, errorKey, label, min, max, step, ...rest } = props;
+ const errorMessage = useErrorMessage(errorKey);
+ return ();
+};
+export const PasswordField = (props) => {
+ const { type, errorKey, label, ...rest } = props;
+ const errorMessage = useErrorMessage(errorKey);
+ return ();
+};
+export const RangeField = (props) => {
+ // Strip candy_wrapper-specific props
+ const { type, errorKey, label, value, defaultValue, ...rest } = props;
+ // Transform string values to numbers for MUI Slider
+ const errorMessage = useErrorMessage(errorKey);
+ const sliderValue = value !== undefined ? Number(value) : undefined;
+ const sliderDefaultValue = defaultValue !== undefined ? Number(defaultValue) : undefined;
+ // Spread rest into Slider
+ return (
+ {label}
+
+
+ {errorMessage && {errorMessage}}
+ );
+};
+export const Checkbox = (props) => {
+ // Strip candy_wrapper-specific props
+ const { type, includeHidden, uncheckedValue, errorKey, label, ...rest } = props;
+ // Transform
+ const errorMessage = useErrorMessage(errorKey);
+ const { name } = rest;
+ // Spread rest into MuiCheckbox (defaultChecked/checked pass through natively)
+ return (
+ {includeHidden && ()}
+ }/>
+ {errorMessage && {errorMessage}}
+ );
+};
+export const CollectionCheckboxes = (props) => {
+ // Strip candy_wrapper-specific props
+ const { includeHidden, collection, label, errorKey } = props;
+ const errorMessage = useErrorMessage(errorKey);
+ if (collection.length === 0) {
+ return null;
+ }
+ const { name } = collection[0];
+ const checkboxes = collection.map((item) => {
+ // Strip candy_wrapper-specific props from each collection item
+ const { label: checkboxLabel, type, includeHidden: _ih, uncheckedValue: _uv, ...rest } = item;
+ return (}/>);
+ });
+ return (
+ {includeHidden && ()}
+ {label}
+ {checkboxes}
+ {errorMessage && {errorMessage}}
+ );
+};
+export const CollectionRadioButtons = (props) => {
+ // Strip candy_wrapper-specific props
+ const { includeHidden, collection, label, errorKey } = props;
+ const errorMessage = useErrorMessage(errorKey);
+ if (collection.length === 0) {
+ return null;
+ }
+ // Transform: extract value/defaultValue for RadioGroup from checked/defaultChecked items
+ const defaultItem = collection.find((option) => !!option.defaultChecked);
+ const checkedItem = collection.find((option) => !!option.checked);
+ const valueProps = {};
+ if (defaultItem) {
+ valueProps.defaultValue = defaultItem.value;
+ }
+ else if (checkedItem) {
+ valueProps.value = checkedItem.value;
+ }
+ const { name } = collection[0];
+ const radioButtons = collection.map((item) => {
+ // Strip candy_wrapper-specific props from each collection item
+ const { label: radioLabel, checked, defaultChecked, type, ...rest } = item;
+ return (}/>);
+ });
+ return (
+ {includeHidden && ()}
+ {label}
+
+ {radioButtons}
+
+ {errorMessage && {errorMessage}}
+ );
+};
+export const Select = (props) => {
+ // Strip candy_wrapper-specific props
+ const { type, includeHidden, options, errorKey, label, name, id, multiple, ...rest } = props;
+ // Transform
+ const errorMessage = useErrorMessage(errorKey);
+ const addHidden = includeHidden && multiple;
+ // Transform options into MenuItem children (flattening optgroups)
+ const menuItems = options.flatMap((item) => {
+ if ('options' in item) {
+ return item.options.map((opt) => ());
+ }
+ else {
+ return ();
+ }
+ });
+ const labelId = id ? `${id}-label` : undefined;
+ // Spread rest into MuiSelect
+ return (
+ {addHidden && ()}
+ {label}
+
+ {menuItems}
+
+ {errorMessage && {errorMessage}}
+ );
+};
+export const TextArea = (props) => {
+ const { type, errorKey, label, rows, ...rest } = props;
+ const errorMessage = useErrorMessage(errorKey);
+ return ();
+};
+export const FileField = (props) => {
+ const { type, errorKey, label, ...rest } = props;
+ const errorMessage = useErrorMessage(errorKey);
+ return ();
+};
+export const SubmitButton = (props) => {
+ const { type, text, ...rest } = props;
+ return ();
+};
diff --git a/wrappers/js/react-spectrum/s2/index.jsx b/wrappers/js/react-spectrum/s2/index.jsx
new file mode 100644
index 0000000..e177f49
--- /dev/null
+++ b/wrappers/js/react-spectrum/s2/index.jsx
@@ -0,0 +1,300 @@
+/**
+ * A set of [candy_wrappers](https://github.com/thoughtbot/candy_wrapper) around
+ * React Spectrum S2 input components. It works with the output from
+ * [FormProps](https://github.com/thoughtbot/form_props).
+ *
+ * You modify these components to fit your design needs.
+ */
+import React, { createContext, useContext, useMemo } from 'react';
+import { parseDate, parseDateTime, parseTime, } from '@internationalized/date';
+import { TextField as SpectrumTextField } from '@react-spectrum/s2/TextField';
+import { TextArea as SpectrumTextArea } from '@react-spectrum/s2/TextArea';
+import { NumberField as SpectrumNumberField } from '@react-spectrum/s2/NumberField';
+import { DateField as SpectrumDateField } from '@react-spectrum/s2/DateField';
+import { TimeField as SpectrumTimeField } from '@react-spectrum/s2/TimeField';
+import { DatePicker as SpectrumDatePicker } from '@react-spectrum/s2/DatePicker';
+import { ColorField as SpectrumColorField } from '@react-spectrum/s2/ColorField';
+import { SearchField as SpectrumSearchField } from '@react-spectrum/s2/SearchField';
+import { Checkbox as SpectrumCheckbox, } from '@react-spectrum/s2/Checkbox';
+import { CheckboxGroup as SpectrumCheckboxGroup, } from '@react-spectrum/s2/CheckboxGroup';
+import { RadioGroup as SpectrumRadioGroup, Radio as SpectrumRadio, } from '@react-spectrum/s2/RadioGroup';
+import { Picker as SpectrumPicker, PickerItem, PickerSection, } from '@react-spectrum/s2/Picker';
+import { Slider as SpectrumSlider } from '@react-spectrum/s2/Slider';
+import { Button as SpectrumButton } from '@react-spectrum/s2/Button';
+import { Form as SpectrumForm } from '@react-spectrum/s2/Form';
+import { FileTrigger as SpectrumFileTrigger } from '@react-spectrum/s2/FileTrigger';
+export const ValidationContext = createContext({});
+export const useErrorMessage = (errorKey) => {
+ const errors = useContext(ValidationContext);
+ return useMemo(() => {
+ if (!errorKey) {
+ return null;
+ }
+ const validationError = errors[errorKey];
+ const hasErrors = errorKey && validationError;
+ if (!hasErrors) {
+ return null;
+ }
+ const errorMessages = Array.isArray(validationError)
+ ? validationError
+ : [validationError];
+ return errorMessages.join(' ');
+ }, [errors, errorKey]);
+};
+export const Extras = (hiddenInputAttributes) => {
+ const hiddenProps = Object.values(hiddenInputAttributes);
+ const hiddenInputs = hiddenProps.map((props) => ());
+ return <>{hiddenInputs}>;
+};
+export const Form = ({ extras, validationErrors = {}, children, ...props }) => {
+ const formProps = props;
+ return (
+
+
+ {children}
+
+ );
+};
+export const Checkbox = ({ type: _type, includeHidden, uncheckedValue, errorKey, label, checked, defaultChecked, ...rest }) => {
+ const errorMessage = useErrorMessage(errorKey);
+ // Transform: Rails checked/defaultChecked → Spectrum isSelected/defaultSelected
+ const isSelected = checked ?? undefined;
+ const defaultSelected = defaultChecked ?? undefined;
+ return (<>
+ {includeHidden && ()}
+
+ {label}
+
+ >);
+};
+export const CollectionCheckboxes = ({ includeHidden, collection, label, errorKey, }) => {
+ const errorMessage = useErrorMessage(errorKey);
+ if (collection.length === 0) {
+ return null;
+ }
+ const defaultItems = collection.filter((option) => !!option.defaultChecked);
+ const items = collection.filter((option) => !!option.checked);
+ const valueProps = {};
+ if (defaultItems.length > 0) {
+ valueProps.defaultValue = defaultItems.map((option) => option.value);
+ }
+ else if (items.length > 0) {
+ valueProps.value = items.map((option) => option.value);
+ }
+ const { name } = collection[0];
+ return (<>
+ {includeHidden && ()}
+
+ {collection.map((option) => (
+ {option.label}
+ ))}
+
+ >);
+};
+export const CollectionRadioButtons = ({ includeHidden, collection, label, errorKey, }) => {
+ const errorMessage = useErrorMessage(errorKey);
+ if (collection.length === 0) {
+ return null;
+ }
+ const defaultItem = collection.find((option) => !!option.defaultChecked);
+ const item = collection.find((option) => !!option.checked);
+ const valueProps = {};
+ if (defaultItem) {
+ valueProps.defaultValue = defaultItem.value;
+ }
+ else if (item) {
+ valueProps.value = item.value;
+ }
+ const { name } = collection[0];
+ return (<>
+ {includeHidden && ()}
+
+ {collection.map((option) => (
+ {option.label}
+ ))}
+
+ >);
+};
+export const TextField = ({ type: _type, label, errorKey, size: _size, ...rest }) => {
+ const errorMessage = useErrorMessage(errorKey);
+ return ();
+};
+export const EmailField = ({ type: _type, label, errorKey, size: _size, ...rest }) => {
+ const errorMessage = useErrorMessage(errorKey);
+ return ();
+};
+export const ColorField = ({ type: _type, label, errorKey, ...rest }) => {
+ const errorMessage = useErrorMessage(errorKey);
+ return ();
+};
+export const DateField = ({ type: _type, label, errorKey, value, defaultValue, min, max, ...rest }) => {
+ const errorMessage = useErrorMessage(errorKey);
+ // Transform: string values → CalendarDate objects
+ const valueProps = {};
+ if (value) {
+ valueProps.value = parseDate(value);
+ }
+ else if (defaultValue) {
+ valueProps.defaultValue = parseDate(defaultValue);
+ }
+ // Transform: min/max → minValue/maxValue
+ const minMaxProps = {};
+ if (min) {
+ minMaxProps.minValue = parseDate(min);
+ }
+ if (max) {
+ minMaxProps.maxValue = parseDate(max);
+ }
+ return ();
+};
+export const DateTimeLocalField = ({ type: _type, label, errorKey, value, defaultValue, min, max, ...rest }) => {
+ const errorMessage = useErrorMessage(errorKey);
+ // Transform: string values → CalendarDateTime objects
+ const valueProps = {};
+ if (value) {
+ valueProps.value = parseDateTime(value);
+ }
+ else if (defaultValue) {
+ valueProps.defaultValue = parseDateTime(defaultValue);
+ }
+ // Transform: min/max → minValue/maxValue
+ const minMaxProps = {};
+ if (min) {
+ minMaxProps.minValue = parseDateTime(min);
+ }
+ if (max) {
+ minMaxProps.maxValue = parseDateTime(max);
+ }
+ return ();
+};
+export const TimeField = ({ type: _type, label, errorKey, value, defaultValue, ...rest }) => {
+ const errorMessage = useErrorMessage(errorKey);
+ // Transform: string values → Time objects
+ const valueProps = {};
+ if (value) {
+ valueProps.value = parseTime(value);
+ }
+ else if (defaultValue) {
+ valueProps.defaultValue = parseTime(defaultValue);
+ }
+ return ();
+};
+export const SearchField = ({ type: _type, label, errorKey, size: _size, autosave: _autosave, results: _results, onsearch: _onsearch, incremental: _incremental, ...rest }) => {
+ const errorMessage = useErrorMessage(errorKey);
+ return ();
+};
+export const TelField = ({ type: _type, label, errorKey, size: _size, ...rest }) => {
+ const errorMessage = useErrorMessage(errorKey);
+ return ();
+};
+export const UrlField = ({ type: _type, label, errorKey, size: _size, ...rest }) => {
+ const errorMessage = useErrorMessage(errorKey);
+ return ();
+};
+export const MonthField = ({ type: _type, label, errorKey, min: _min, max: _max, ...rest }) => {
+ const errorMessage = useErrorMessage(errorKey);
+ return ();
+};
+export const NumberField = ({ type: _type, label, errorKey, value, defaultValue, min, max, ...rest }) => {
+ const errorMessage = useErrorMessage(errorKey);
+ // Transform: string/number values → number
+ const valueProps = {};
+ if (value !== undefined && value !== '') {
+ valueProps.value = Number(value);
+ }
+ else if (defaultValue !== undefined && defaultValue !== '') {
+ valueProps.defaultValue = Number(defaultValue);
+ }
+ return ();
+};
+export const RangeField = ({ type: _type, label, errorKey, value, defaultValue, min, max, ...rest }) => {
+ const errorMessage = useErrorMessage(errorKey);
+ const numericValue = value ? Number(value) : undefined;
+ const numericDefault = defaultValue ? Number(defaultValue) : undefined;
+ return (<>
+
+
+ {errorMessage && {errorMessage}}
+ >);
+};
+export const PasswordField = ({ type: _type, label, errorKey, size: _size, ...rest }) => {
+ const errorMessage = useErrorMessage(errorKey);
+ return ();
+};
+export const Select = ({ includeHidden, name, id, options, label, errorKey, multiple, type: _type, ...rest }) => {
+ const errorMessage = useErrorMessage(errorKey);
+ const addHidden = includeHidden && multiple;
+ const selectedValue = 'value' in rest ? rest.value : undefined;
+ const selectedDefault = 'defaultValue' in rest ? rest.defaultValue : undefined;
+ const hasGroups = options.some((item) => 'options' in item);
+ const selectionProps = {};
+ if (multiple) {
+ selectionProps.selectionMode = 'multiple';
+ if (selectedValue) {
+ selectionProps.value = Array.isArray(selectedValue)
+ ? selectedValue
+ : [selectedValue];
+ }
+ else if (selectedDefault) {
+ selectionProps.defaultValue = Array.isArray(selectedDefault)
+ ? selectedDefault
+ : [selectedDefault];
+ }
+ }
+ else {
+ if (selectedValue) {
+ selectionProps.value = Array.isArray(selectedValue)
+ ? selectedValue[0]
+ : selectedValue;
+ }
+ else if (selectedDefault) {
+ selectionProps.defaultValue = Array.isArray(selectedDefault)
+ ? selectedDefault[0]
+ : selectedDefault;
+ }
+ }
+ return (<>
+ {addHidden && ()}
+
+ {hasGroups
+ ? options.map((item) => {
+ if ('options' in item) {
+ return (
+ {item.options.map((opt) => (
+ {opt.label}
+ ))}
+ );
+ }
+ return (
+ {item.label}
+ );
+ })
+ : options.map((item) => {
+ if ('options' in item) {
+ return null;
+ }
+ return (
+ {item.label}
+ );
+ })}
+
+ >);
+};
+export const TextArea = ({ type: _type, label, errorKey, ...rest }) => {
+ const errorMessage = useErrorMessage(errorKey);
+ return ();
+};
+export const FileField = ({ type: _type, label, errorKey }) => {
+ const errorMessage = useErrorMessage(errorKey);
+ return (<>
+
+ {label}
+
+ {errorMessage && {errorMessage}}
+ >);
+};
+export const SubmitButton = ({ type: _type, text, ...rest }) => {
+ return (
+ {text}
+ );
+};
diff --git a/wrappers/ts/ark/v5/index.tsx b/wrappers/ts/ark/v5/index.tsx
new file mode 100644
index 0000000..c0e6d90
--- /dev/null
+++ b/wrappers/ts/ark/v5/index.tsx
@@ -0,0 +1,707 @@
+/**
+ * A set of [candy_wrappers](https://github.com/thoughtbot/candy_wrapper) around
+ * Ark UI v5 input components. It works with the output from
+ * [FormProps](https://github.com/thoughtbot/form_props).
+ *
+ * You modify these components to fit your design needs.
+ */
+
+import React, { createContext, useContext, useMemo } from 'react'
+
+export type {
+ CheckboxField as RailsCheckboxFieldProps,
+ CollectionCheckboxesField as RailsCollectionCheckboxesFieldProps,
+ CollectionRadioButtonsField as RailsCollectionRadioButtonsFieldProps,
+ ColorField as RailsColorFieldProps,
+ DateField as RailsDateFieldProps,
+ DateTimeLocalField as RailsDateTimeLocalFieldProps,
+ EmailField as RailsEmailFieldProps,
+ FileField as RailsFileFieldProps,
+ HiddenField as RailsHiddenFieldProps,
+ MonthField as RailsMonthFieldProps,
+ NumberField as RailsNumberFieldProps,
+ PasswordField as RailsPasswordFieldProps,
+ RangeField as RailsRangeFieldProps,
+ SearchField as RailsSearchFieldProps,
+ Select as RailsSelectProps,
+ SubmitProps as RailsSubmitButtonProps,
+ TelField as RailsTelFieldProps,
+ TextArea as RailsTextAreaProps,
+ TextField as RailsTextFieldProps,
+ TimeField as RailsTimeFieldProps,
+ UrlField as RailsUrlFieldProps,
+ HTMLFormProps as RailsHTMLFormProps,
+ ValidationErrors,
+} from '@thoughtbot/candy_wrapper'
+
+import {
+ CheckboxField as RailsCheckboxField,
+ CollectionCheckboxesField as RailsCollectionCheckboxesField,
+ CollectionRadioButtonsField as RailsCollectionRadioButtonsField,
+ ColorField as RailsColorField,
+ DateField as RailsDateField,
+ DateTimeLocalField as RailsDateTimeLocalField,
+ EmailField as RailsEmailField,
+ FileField as RailsFileField,
+ HiddenField as RailsHiddenField,
+ MonthField as RailsMonthField,
+ NumberField as RailsNumberField,
+ PasswordField as RailsPasswordField,
+ RangeField as RailsRangeField,
+ SearchField as RailsSearchField,
+ SingleSelect as RailsSingleSelect,
+ MultiSelect as RailsMultiSelect,
+ SubmitProps as RailsSubmitButton,
+ TelField as RailsTelField,
+ TextArea as RailsTextArea,
+ TextField as RailsTextField,
+ TimeField as RailsTimeField,
+ UrlField as RailsUrlField,
+ HTMLFormProps as RailsHTMLFormProps,
+ ValidationErrors,
+} from '@thoughtbot/candy_wrapper'
+
+import { Checkbox } from '@ark-ui/react/checkbox'
+import { RadioGroup } from '@ark-ui/react/radio-group'
+import { Select, createListCollection } from '@ark-ui/react/select'
+import { NumberInput } from '@ark-ui/react/number-input'
+import { Slider } from '@ark-ui/react/slider'
+import { ColorPicker, parseColor } from '@ark-ui/react/color-picker'
+import { DateInput } from '@ark-ui/react/date-input'
+import { Field } from '@ark-ui/react/field'
+import { FileUpload } from '@ark-ui/react/file-upload'
+import { Portal } from '@ark-ui/react/portal'
+
+export const ValidationContext = createContext({})
+
+export const useErrorMessage = (errorKey?: string) => {
+ const errors = useContext(ValidationContext)
+
+ return useMemo(() => {
+ if (!errorKey) {
+ return null
+ }
+
+ const validationError = errors[errorKey]
+ const hasErrors = errorKey && validationError
+
+ if (!hasErrors) {
+ return null
+ }
+
+ const errorMessages = Array.isArray(validationError)
+ ? validationError
+ : [validationError]
+
+ return errorMessages.join(' ')
+ }, [errors, errorKey])
+}
+
+export type ExtrasProps = Record
+
+export const Extras = (hiddenInputAttributes: ExtrasProps) => {
+ const hiddenProps = Object.values(hiddenInputAttributes)
+ const hiddenInputs = hiddenProps.map((props: RailsHiddenField) => (
+
+ ))
+
+ return <>{hiddenInputs}>
+}
+
+export interface FormProps {
+ extras: ExtrasProps
+ inputs: T
+ form: RailsHTMLFormProps
+}
+
+type FormElementProps = React.FormHTMLAttributes & {
+ extras: ExtrasProps
+ validationErrors?: ValidationErrors
+}
+
+export const Form = ({
+ extras,
+ validationErrors = {},
+ children,
+ ...props
+}: FormElementProps) => {
+ return (
+
+ )
+}
+
+type InputProps = {
+ label: string
+ errorKey?: string
+}
+
+const FieldWrapperWithError = ({
+ label,
+ id,
+ errorKey,
+ children,
+}: {
+ label: string
+ id?: string
+ errorKey?: string
+ children: React.ReactNode
+}) => {
+ const errorMessage = useErrorMessage(errorKey)
+
+ return (
+
+ {label}
+ {children}
+ {errorMessage && {errorMessage}}
+
+ )
+}
+
+type CheckboxProps = RailsCheckboxField & InputProps
+
+export const CheckboxComponent = ({
+ type: _type,
+ includeHidden,
+ uncheckedValue,
+ errorKey,
+ label,
+ ...rest
+}: CheckboxProps) => {
+ const errorMessage = useErrorMessage(errorKey)
+
+ return (
+ <>
+ {includeHidden && (
+
+ )}
+
+
+ ✓
+
+ {label}
+
+
+ >
+ )
+}
+
+export { CheckboxComponent as Checkbox }
+
+type CollectionCheckboxesFieldProps = RailsCollectionCheckboxesField &
+ InputProps
+
+export const CollectionCheckboxes = ({
+ includeHidden,
+ collection,
+ label,
+ errorKey,
+}: CollectionCheckboxesFieldProps) => {
+ const errorMessage = useErrorMessage(errorKey)
+
+ if (collection.length === 0) {
+ return null
+ }
+
+ const { name } = collection[0]
+ const defaultItems = collection.filter((option) => !!option.defaultChecked)
+ const items = collection.filter((option) => !!option.checked)
+ const valueProps: { value?: string[]; defaultValue?: string[] } = {}
+ if (defaultItems.length > 0) {
+ valueProps.defaultValue = defaultItems.map((option) => option.value)
+ } else if (items.length > 0) {
+ valueProps.value = items.map((option) => option.value)
+ }
+
+ return (
+ <>
+ {includeHidden && (
+
+ )}
+
+
+ {collection.map((option) => (
+
+
+ ✓
+
+ {option.label}
+
+
+ ))}
+
+ >
+ )
+}
+
+type CollectionRadioButtonsFieldProps = RailsCollectionRadioButtonsField &
+ InputProps
+
+export const CollectionRadioButtons = ({
+ includeHidden,
+ collection,
+ label,
+ errorKey,
+}: CollectionRadioButtonsFieldProps) => {
+ const errorMessage = useErrorMessage(errorKey)
+
+ if (collection.length === 0) {
+ return null
+ }
+
+ const { name } = collection[0]
+ const defaultItem = collection.find((option) => !!option.defaultChecked)
+ const item = collection.find((option) => !!option.checked)
+ const valueProps: { value?: string; defaultValue?: string } = {}
+ if (defaultItem) {
+ valueProps.defaultValue = defaultItem.value
+ } else if (item) {
+ valueProps.value = item.value
+ }
+
+ return (
+ <>
+ {includeHidden && (
+
+ )}
+
+ {label}
+ {collection.map((option) => (
+
+
+ {option.label}
+
+
+ ))}
+
+ >
+ )
+}
+
+export type TextFieldProps = RailsTextField & InputProps
+
+export const TextField = ({
+ type: _type,
+ label,
+ errorKey,
+ ...rest
+}: TextFieldProps) => {
+ return (
+
+
+
+ )
+}
+
+export type EmailFieldProps = RailsEmailField & InputProps
+
+export const EmailField = ({
+ type: _type,
+ label,
+ errorKey,
+ ...rest
+}: EmailFieldProps) => {
+ return (
+
+
+
+ )
+}
+
+export type ColorFieldProps = RailsColorField & InputProps
+
+export const ColorField = ({
+ type: _type,
+ label,
+ errorKey,
+ ...rest
+}: ColorFieldProps) => {
+ const errorMessage = useErrorMessage(errorKey)
+ const { name, value, defaultValue, ...colorRest } = rest
+ const colorValue = parseColor(value || defaultValue || '#000000')
+
+ return (
+
+
+ {label}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {errorMessage && {errorMessage}}
+
+ )
+}
+
+export type DateFieldProps = RailsDateField & InputProps
+
+export const DateField = ({
+ type: _type,
+ label,
+ errorKey,
+ value,
+ defaultValue,
+ ...rest
+}: DateFieldProps) => {
+ return (
+
+
+
+ )
+}
+
+export type DateTimeLocalFieldProps = RailsDateTimeLocalField & InputProps
+
+export const DateTimeLocalField = ({
+ type: _type,
+ label,
+ errorKey,
+ value,
+ defaultValue,
+ ...rest
+}: DateTimeLocalFieldProps) => {
+ return (
+
+
+
+ )
+}
+
+export type TimeFieldProps = RailsTimeField & InputProps
+
+export const TimeField = ({
+ type: _type,
+ label,
+ errorKey,
+ value,
+ defaultValue,
+ ...rest
+}: TimeFieldProps) => {
+ return (
+
+
+
+ )
+}
+
+export type MonthFieldProps = RailsMonthField & InputProps
+
+export const MonthField = ({
+ type: _type,
+ label,
+ errorKey,
+ value,
+ defaultValue,
+ ...rest
+}: MonthFieldProps) => {
+ return (
+
+
+
+ )
+}
+
+export type SearchFieldProps = RailsSearchField & InputProps
+
+export const SearchField = ({
+ type: _type,
+ label,
+ errorKey,
+ autosave: _autosave,
+ results: _results,
+ onsearch: _onsearch,
+ incremental: _incremental,
+ ...rest
+}: SearchFieldProps) => {
+ return (
+
+
+
+ )
+}
+
+export type TelFieldProps = RailsTelField & InputProps
+
+export const TelField = ({
+ type: _type,
+ label,
+ errorKey,
+ ...rest
+}: TelFieldProps) => {
+ return (
+
+
+
+ )
+}
+
+export type UrlFieldProps = RailsUrlField & InputProps
+
+export const UrlField = ({
+ type: _type,
+ label,
+ errorKey,
+ ...rest
+}: UrlFieldProps) => {
+ return (
+
+
+
+ )
+}
+
+export type PasswordFieldProps = RailsPasswordField & InputProps
+
+export const PasswordField = ({
+ type: _type,
+ label,
+ errorKey,
+ ...rest
+}: PasswordFieldProps) => {
+ return (
+
+
+
+ )
+}
+
+export type NumberFieldProps = RailsNumberField & InputProps
+
+export const NumberField = ({
+ type: _type,
+ label,
+ errorKey,
+ value,
+ defaultValue,
+ ...rest
+}: NumberFieldProps) => {
+ const errorMessage = useErrorMessage(errorKey)
+
+ const numericDefault =
+ defaultValue !== undefined && defaultValue !== ''
+ ? String(defaultValue)
+ : undefined
+ const numericValue =
+ value !== undefined && value !== '' ? String(value) : undefined
+
+ return (
+
+
+ {label}
+
+
+ +
+ -
+
+
+ {errorMessage && {errorMessage}}
+
+ )
+}
+
+export type RangeFieldProps = RailsRangeField & InputProps
+
+export const RangeField = ({
+ type: _type,
+ label,
+ errorKey,
+ value,
+ defaultValue,
+ ...rest
+}: RangeFieldProps) => {
+ const errorMessage = useErrorMessage(errorKey)
+ const numericDefault = defaultValue ? [Number(defaultValue)] : undefined
+ const numericValue = value ? [Number(value)] : undefined
+
+ return (
+
+
+ {label}
+
+
+
+
+
+
+
+
+
+
+ {errorMessage && {errorMessage}}
+
+ )
+}
+
+type SelectProps = (RailsSingleSelect | RailsMultiSelect) & InputProps
+
+export const SelectComponent = ({
+ includeHidden,
+ options,
+ label,
+ errorKey,
+ multiple,
+ type: _type,
+ ...rest
+}: SelectProps) => {
+ const errorMessage = useErrorMessage(errorKey)
+ const addHidden = includeHidden && multiple
+ const { name, id, value, defaultValue, ...selectRest } = rest as {
+ name: string
+ id?: string
+ value?: string | string[]
+ defaultValue?: string | string[]
+ }
+
+ const flatItems = options.flatMap((item) => {
+ if ('options' in item) {
+ return item.options
+ }
+ return [item]
+ })
+
+ const collection = createListCollection({ items: flatItems })
+
+ const valueProps: Record = {}
+ if (multiple) {
+ valueProps.multiple = true
+ if (value && Array.isArray(value)) {
+ valueProps.value = value
+ } else if (defaultValue && Array.isArray(defaultValue)) {
+ valueProps.defaultValue = defaultValue
+ }
+ } else {
+ if (value) {
+ valueProps.value = Array.isArray(value) ? value : [value]
+ } else if (defaultValue) {
+ valueProps.defaultValue = Array.isArray(defaultValue)
+ ? defaultValue
+ : [defaultValue]
+ }
+ }
+
+ return (
+
+ {addHidden && (
+
+ )}
+
+ {label}
+
+
+
+
+
+
+
+
+ {flatItems.map((item) => (
+
+ {item.label}
+ ✓
+
+ ))}
+
+
+
+
+
+ {errorMessage && {errorMessage}}
+
+ )
+}
+
+export { SelectComponent as Select }
+
+export type TextAreaProps = RailsTextArea & InputProps
+
+export const TextArea = ({
+ type: _type,
+ label,
+ errorKey,
+ ...rest
+}: TextAreaProps) => {
+ return (
+
+
+
+ )
+}
+
+export type FileFieldProps = RailsFileField & InputProps
+
+export const FileField = ({
+ type: _type,
+ label,
+ errorKey,
+ ...rest
+}: FileFieldProps) => {
+ const errorMessage = useErrorMessage(errorKey)
+
+ return (
+
+
+ {label}
+ Choose file
+
+
+ {errorMessage && {errorMessage}}
+
+ )
+}
+
+export type SubmitButtonProps = RailsSubmitButton
+
+export const SubmitButton = ({
+ type: _type,
+ text,
+ ...rest
+}: SubmitButtonProps) => {
+ return (
+
+ )
+}
diff --git a/wrappers/ts/ark/v5/package.json b/wrappers/ts/ark/v5/package.json
new file mode 100644
index 0000000..23fdf4c
--- /dev/null
+++ b/wrappers/ts/ark/v5/package.json
@@ -0,0 +1,11 @@
+{
+ "name": "@candy_wrapper/ark-v5",
+ "private": true,
+ "scripts": {
+ "typecheck": "tsc --noEmit",
+ "build": "tsc --project tsconfig.build.json && mkdir -p ../../../../wrappers/js/ark/v5 && cp dist/wrappers/ts/ark/v5/index.jsx ../../../../wrappers/js/ark/v5/index.jsx && rm -rf dist"
+ },
+ "devDependencies": {
+ "@ark-ui/react": "^5.38.2"
+ }
+}
diff --git a/wrappers/ts/ark/v5/tsconfig.build.json b/wrappers/ts/ark/v5/tsconfig.build.json
new file mode 100644
index 0000000..34f2413
--- /dev/null
+++ b/wrappers/ts/ark/v5/tsconfig.build.json
@@ -0,0 +1,14 @@
+{
+ "extends": "../../../../tsconfig",
+ "compilerOptions": {
+ "removeComments": false,
+ "declaration": false,
+ "moduleResolution": "bundler",
+ "skipLibCheck": true,
+ "outDir": "./dist",
+ "paths": {
+ "@thoughtbot/candy_wrapper": ["../../../../src/index"]
+ }
+ },
+ "include": ["./index.tsx"]
+}
diff --git a/wrappers/ts/ark/v5/tsconfig.json b/wrappers/ts/ark/v5/tsconfig.json
new file mode 100644
index 0000000..3ddf145
--- /dev/null
+++ b/wrappers/ts/ark/v5/tsconfig.json
@@ -0,0 +1,10 @@
+{
+ "extends": "../../../../tsconfig",
+ "compilerOptions": {
+ "noEmit": true,
+ "paths": {
+ "@thoughtbot/candy_wrapper": ["../../../../src/index"]
+ }
+ },
+ "include": ["./**/*.ts", "./**/*.tsx"]
+}
diff --git a/wrappers/ts/chakra/v3/Checkbox.test.jsx b/wrappers/ts/chakra/v3/Checkbox.test.jsx
new file mode 100644
index 0000000..cf4bce5
--- /dev/null
+++ b/wrappers/ts/chakra/v3/Checkbox.test.jsx
@@ -0,0 +1,138 @@
+import React from 'react'
+import { render } from '@testing-library/react'
+import { ChakraProvider, defaultSystem } from '@chakra-ui/react'
+import { Checkbox, ValidationContext } from '.'
+
+const buildPayload = () => {
+ return {
+ type: 'checkbox',
+ value: '1',
+ uncheckedValue: '0',
+ defaultChecked: true,
+ name: 'post[admin]',
+ id: 'post_admin',
+ }
+}
+
+describe('Checkbox', () => {
+ it('renders', () => {
+ const payload = buildPayload()
+
+ const { getByRole } = render(
+
+
+
+ )
+
+ const element = getByRole('checkbox')
+ expect(element).not.toBe(null)
+ expect(element.value).toEqual('1')
+ expect(element.type).toEqual('checkbox')
+ })
+
+ it('renders the label', () => {
+ const payload = buildPayload()
+
+ const { getByText } = render(
+
+
+
+ )
+
+ expect(getByText('Is admin')).not.toBeNull()
+ })
+
+ it('renders defaultChecked', () => {
+ const payload = buildPayload()
+ payload.defaultChecked = true
+
+ const { getByRole } = render(
+
+
+
+ )
+
+ const input = getByRole('checkbox')
+ expect(input.checked).toEqual(true)
+ })
+
+ it('renders defaultChecked false', () => {
+ const payload = buildPayload()
+ payload.defaultChecked = false
+
+ const { getByRole } = render(
+
+
+
+ )
+
+ const input = getByRole('checkbox')
+ expect(input.checked).toEqual(false)
+ })
+
+ it('renders checked', () => {
+ const payload = buildPayload()
+ delete payload.defaultChecked
+ payload.checked = true
+
+ const { getByRole } = render(
+
+
+
+ )
+
+ const input = getByRole('checkbox')
+ expect(input.checked).toEqual(true)
+ })
+
+ it('renders unchecked', () => {
+ const payload = buildPayload()
+ delete payload.defaultChecked
+ payload.checked = false
+
+ const { getByRole } = render(
+
+
+
+ )
+
+ const input = getByRole('checkbox')
+ expect(input.checked).toEqual(false)
+ })
+
+ it('adds a hidden input if includeHidden is true', () => {
+ const payload = buildPayload()
+ payload.includeHidden = true
+ payload.uncheckedValue = '10'
+
+ const { container } = render(
+
+
+
+ )
+
+ const hiddenInput = container.querySelector('input[type=hidden]')
+ expect(hiddenInput).not.toBe(null)
+ expect(hiddenInput.name).toEqual('post[admin]')
+ expect(hiddenInput.value).toEqual('10')
+ })
+
+ it('renders with errors', async () => {
+ const payload = buildPayload()
+
+ const validationErrors = {
+ is_admin: 'Admin invalid',
+ }
+
+ const { getByText } = render(
+
+
+
+
+
+ )
+
+ const element = getByText('Admin invalid')
+ expect(element).not.toBe(null)
+ })
+})
diff --git a/wrappers/ts/chakra/v3/CollectionCheckboxes.test.jsx b/wrappers/ts/chakra/v3/CollectionCheckboxes.test.jsx
new file mode 100644
index 0000000..43c6cdc
--- /dev/null
+++ b/wrappers/ts/chakra/v3/CollectionCheckboxes.test.jsx
@@ -0,0 +1,108 @@
+import React from 'react'
+import { render } from '@testing-library/react'
+import { ChakraProvider, defaultSystem } from '@chakra-ui/react'
+import { CollectionCheckboxes, ValidationContext } from '.'
+
+const buildCheckboxPayload = (value, label) => {
+ return {
+ type: 'checkbox',
+ value,
+ defaultChecked: true,
+ label,
+ name: 'post[author_ids][]',
+ id: `post_author_ids_${value}`,
+ }
+}
+
+const buildPayload = () => {
+ return {
+ collection: [
+ buildCheckboxPayload(1, 'one'),
+ buildCheckboxPayload(2, 'two'),
+ ],
+ includeHidden: true,
+ label: 'authors',
+ required: false,
+ }
+}
+
+describe('CollectionCheckboxes', () => {
+ it('renders', () => {
+ const payload = buildPayload()
+
+ const { getAllByRole, getByText } = render(
+
+
+
+ )
+
+ const checkboxes = getAllByRole('checkbox')
+ expect(checkboxes.length).toEqual(2)
+
+ expect(getByText('one')).not.toBeNull()
+ expect(getByText('two')).not.toBeNull()
+ })
+
+ it('renders nothing when the collection is blank', () => {
+ const payload = buildPayload()
+ payload.collection = []
+
+ const { container } = render(
+
+
+
+ )
+
+ expect(container.innerHTML).toEqual('')
+ })
+
+ it('renders hidden input', () => {
+ const payload = buildPayload()
+ payload.includeHidden = true
+
+ const { container } = render(
+
+
+
+ )
+
+ const hiddenInput = container.querySelector('input[type=hidden]')
+ expect(hiddenInput.name).toEqual('post[author_ids][]')
+ expect(hiddenInput.value).toEqual('')
+ })
+
+ it('renders with error highlighting', async () => {
+ const payload = buildPayload()
+
+ const validationErrors = {
+ author_ids: 'id invalid',
+ }
+
+ const { getByText } = render(
+
+
+
+
+
+ )
+
+ const errorField = getByText('id invalid')
+ expect(errorField).not.toBeNull()
+ })
+})
diff --git a/wrappers/ts/chakra/v3/CollectionRadioButtons.test.jsx b/wrappers/ts/chakra/v3/CollectionRadioButtons.test.jsx
new file mode 100644
index 0000000..b9abead
--- /dev/null
+++ b/wrappers/ts/chakra/v3/CollectionRadioButtons.test.jsx
@@ -0,0 +1,108 @@
+import React from 'react'
+import { render } from '@testing-library/react'
+import { ChakraProvider, defaultSystem } from '@chakra-ui/react'
+import { CollectionRadioButtons, ValidationContext } from '.'
+
+const buildRadioButtonPayload = (value, label, rest) => {
+ return {
+ type: 'radio',
+ value,
+ label,
+ name: 'post[subscribe]',
+ id: `post_subscribe_${value}`,
+ ...rest,
+ }
+}
+
+const buildPayload = () => {
+ return {
+ collection: [
+ buildRadioButtonPayload(1, 'one', { defaultChecked: true }),
+ buildRadioButtonPayload(2, 'two'),
+ ],
+ includeHidden: true,
+ label: 'Subscribe',
+ required: false,
+ }
+}
+
+describe('CollectionRadioButtons', () => {
+ it('renders', () => {
+ const payload = buildPayload()
+
+ const { getAllByRole, getByText } = render(
+
+
+
+ )
+
+ const radios = getAllByRole('radio')
+ expect(radios.length).toEqual(2)
+
+ expect(getByText('one')).not.toBeNull()
+ expect(getByText('two')).not.toBeNull()
+ })
+
+ it('renders nothing when the collection is blank', () => {
+ const payload = buildPayload()
+ payload.collection = []
+
+ const { container } = render(
+
+
+
+ )
+
+ expect(container.innerHTML).toEqual('')
+ })
+
+ it('renders hidden input', () => {
+ const payload = buildPayload()
+ payload.includeHidden = true
+
+ const { container } = render(
+
+
+
+ )
+
+ const hiddenInput = container.querySelector('input[type=hidden]')
+ expect(hiddenInput.name).toEqual('post[subscribe]')
+ expect(hiddenInput.value).toEqual('')
+ })
+
+ it('renders with error highlighting', async () => {
+ const payload = buildPayload()
+
+ const validationErrors = {
+ subscribe: 'id invalid',
+ }
+
+ const { getByText } = render(
+
+
+
+
+
+ )
+
+ const errorField = getByText('id invalid')
+ expect(errorField).not.toBeNull()
+ })
+})
diff --git a/wrappers/ts/chakra/v3/ColorField.test.jsx b/wrappers/ts/chakra/v3/ColorField.test.jsx
new file mode 100644
index 0000000..e564d6b
--- /dev/null
+++ b/wrappers/ts/chakra/v3/ColorField.test.jsx
@@ -0,0 +1,51 @@
+import React from 'react'
+import { render } from '@testing-library/react'
+import { ChakraProvider, defaultSystem } from '@chakra-ui/react'
+import { ColorField, ValidationContext } from '.'
+
+const buildPayload = () => {
+ return {
+ type: 'color',
+ name: 'post[color]',
+ id: 'post_color',
+ required: false,
+ defaultValue: '#000000',
+ }
+}
+
+describe('ColorField', () => {
+ it('renders', () => {
+ const payload = buildPayload()
+
+ const { getByText, container } = render(
+
+
+
+ )
+
+ const label = getByText('Color')
+ expect(label).not.toBeNull()
+
+ const hiddenInput = container.querySelector('input[name="post[color]"]')
+ expect(hiddenInput).not.toBeNull()
+ })
+
+ it('renders with field errors', async () => {
+ const payload = buildPayload()
+
+ const validationErrors = {
+ color: 'color invalid',
+ }
+
+ const { getByText } = render(
+
+
+
+
+
+ )
+
+ const errorField = getByText('color invalid')
+ expect(errorField).not.toBeNull()
+ })
+})
diff --git a/wrappers/ts/chakra/v3/DateField.test.jsx b/wrappers/ts/chakra/v3/DateField.test.jsx
new file mode 100644
index 0000000..293b8c1
--- /dev/null
+++ b/wrappers/ts/chakra/v3/DateField.test.jsx
@@ -0,0 +1,58 @@
+import React from 'react'
+import { render } from '@testing-library/react'
+import { ChakraProvider, defaultSystem } from '@chakra-ui/react'
+import { DateField, ValidationContext } from '.'
+
+const buildPayload = () => {
+ return {
+ type: 'date',
+ name: 'post[publish_date]',
+ id: 'post_publish_date',
+ required: false,
+ defaultValue: '2024-01-01',
+ }
+}
+
+describe('DateField', () => {
+ it('renders', () => {
+ const payload = buildPayload()
+
+ const { getByLabelText } = render(
+
+
+
+ )
+
+ const input = getByLabelText('Publish date')
+ expect(input.required).toBeFalsy()
+ expect(input.value).toEqual('2024-01-01')
+ expect(input.type).toEqual('date')
+ })
+
+ it('renders with field errors', async () => {
+ const payload = buildPayload()
+
+ const validationErrors = {
+ publish_date: 'date invalid',
+ }
+
+ const { getByText } = render(
+
+
+
+
+
+ )
+
+ const errorField = getByText('date invalid')
+ expect(errorField).not.toBeNull()
+ })
+})
diff --git a/wrappers/ts/chakra/v3/DateTimeLocalField.test.jsx b/wrappers/ts/chakra/v3/DateTimeLocalField.test.jsx
new file mode 100644
index 0000000..5fac5ec
--- /dev/null
+++ b/wrappers/ts/chakra/v3/DateTimeLocalField.test.jsx
@@ -0,0 +1,58 @@
+import React from 'react'
+import { render } from '@testing-library/react'
+import { ChakraProvider, defaultSystem } from '@chakra-ui/react'
+import { DateTimeLocalField, ValidationContext } from '.'
+
+const buildPayload = () => {
+ return {
+ type: 'datetime-local',
+ name: 'post[published_at]',
+ id: 'post_published_at',
+ required: false,
+ defaultValue: '2024-01-01T12:00',
+ }
+}
+
+describe('DateTimeLocalField', () => {
+ it('renders', () => {
+ const payload = buildPayload()
+
+ const { getByLabelText } = render(
+
+
+
+ )
+
+ const input = getByLabelText('Published at')
+ expect(input.required).toBeFalsy()
+ expect(input.value).toEqual('2024-01-01T12:00')
+ expect(input.type).toEqual('datetime-local')
+ })
+
+ it('renders with field errors', async () => {
+ const payload = buildPayload()
+
+ const validationErrors = {
+ published_at: 'datetime invalid',
+ }
+
+ const { getByText } = render(
+
+
+
+
+
+ )
+
+ const errorField = getByText('datetime invalid')
+ expect(errorField).not.toBeNull()
+ })
+})
diff --git a/wrappers/ts/chakra/v3/EmailField.test.jsx b/wrappers/ts/chakra/v3/EmailField.test.jsx
new file mode 100644
index 0000000..76530df
--- /dev/null
+++ b/wrappers/ts/chakra/v3/EmailField.test.jsx
@@ -0,0 +1,50 @@
+import React from 'react'
+import { render } from '@testing-library/react'
+import { ChakraProvider, defaultSystem } from '@chakra-ui/react'
+import { EmailField, ValidationContext } from '.'
+
+const buildPayload = () => {
+ return {
+ type: 'email',
+ name: 'post[email]',
+ id: 'post_email',
+ required: false,
+ defaultValue: 'john@smith.com',
+ }
+}
+
+describe('EmailField', () => {
+ it('renders', () => {
+ const payload = buildPayload()
+
+ const { getByLabelText } = render(
+
+
+
+ )
+
+ const input = getByLabelText('Email')
+ expect(input.required).toBeFalsy()
+ expect(input.value).toEqual('john@smith.com')
+ expect(input.type).toEqual('email')
+ })
+
+ it('renders with field errors', async () => {
+ const payload = buildPayload()
+
+ const validationErrors = {
+ email: 'email invalid',
+ }
+
+ const { getByText } = render(
+
+
+
+
+
+ )
+
+ const errorField = getByText('email invalid')
+ expect(errorField).not.toBeNull()
+ })
+})
diff --git a/wrappers/ts/chakra/v3/FileField.test.jsx b/wrappers/ts/chakra/v3/FileField.test.jsx
new file mode 100644
index 0000000..4e02a84
--- /dev/null
+++ b/wrappers/ts/chakra/v3/FileField.test.jsx
@@ -0,0 +1,52 @@
+import React from 'react'
+import { render } from '@testing-library/react'
+import { ChakraProvider, defaultSystem } from '@chakra-ui/react'
+import { FileField, ValidationContext } from '.'
+
+const buildPayload = () => {
+ return {
+ type: 'file',
+ name: 'post[attachment]',
+ id: 'post_attachment',
+ required: false,
+ }
+}
+
+describe('FileField', () => {
+ it('renders', () => {
+ const payload = buildPayload()
+
+ const { getByLabelText } = render(
+
+
+
+ )
+
+ const input = getByLabelText('attachment')
+ expect(input.required).toBeFalsy()
+ expect(input.type).toEqual('file')
+ })
+
+ it('renders with field errors', async () => {
+ const payload = buildPayload()
+
+ const validationErrors = {
+ attachment: 'attachment invalid',
+ }
+
+ const { getByText } = render(
+
+
+
+
+
+ )
+
+ const errorField = getByText('attachment invalid')
+ expect(errorField).not.toBeNull()
+ })
+})
diff --git a/wrappers/ts/chakra/v3/MonthField.test.jsx b/wrappers/ts/chakra/v3/MonthField.test.jsx
new file mode 100644
index 0000000..d455bb7
--- /dev/null
+++ b/wrappers/ts/chakra/v3/MonthField.test.jsx
@@ -0,0 +1,50 @@
+import React from 'react'
+import { render } from '@testing-library/react'
+import { ChakraProvider, defaultSystem } from '@chakra-ui/react'
+import { MonthField, ValidationContext } from '.'
+
+const buildPayload = () => {
+ return {
+ type: 'month',
+ name: 'post[month]',
+ id: 'post_month',
+ required: false,
+ defaultValue: '2024-01',
+ }
+}
+
+describe('MonthField', () => {
+ it('renders', () => {
+ const payload = buildPayload()
+
+ const { getByLabelText } = render(
+
+
+
+ )
+
+ const input = getByLabelText('Month')
+ expect(input.required).toBeFalsy()
+ expect(input.value).toEqual('2024-01')
+ expect(input.type).toEqual('month')
+ })
+
+ it('renders with field errors', async () => {
+ const payload = buildPayload()
+
+ const validationErrors = {
+ month: 'month invalid',
+ }
+
+ const { getByText } = render(
+
+
+
+
+
+ )
+
+ const errorField = getByText('month invalid')
+ expect(errorField).not.toBeNull()
+ })
+})
diff --git a/wrappers/ts/chakra/v3/NumberField.test.jsx b/wrappers/ts/chakra/v3/NumberField.test.jsx
new file mode 100644
index 0000000..544abc8
--- /dev/null
+++ b/wrappers/ts/chakra/v3/NumberField.test.jsx
@@ -0,0 +1,50 @@
+import React from 'react'
+import { render } from '@testing-library/react'
+import { ChakraProvider, defaultSystem } from '@chakra-ui/react'
+import { NumberField, ValidationContext } from '.'
+
+const buildPayload = () => {
+ return {
+ type: 'number',
+ defaultValue: '2',
+ name: 'post[favs]',
+ min: 1,
+ max: 9,
+ id: 'post_favs',
+ step: 2,
+ }
+}
+
+describe('NumberField', () => {
+ it('renders', () => {
+ const payload = buildPayload()
+
+ const { getByRole } = render(
+
+
+
+ )
+
+ const input = getByRole('spinbutton')
+ expect(input.value).toEqual('2')
+ })
+
+ it('renders with field errors', async () => {
+ const payload = buildPayload()
+
+ const validationErrors = {
+ favs: 'favs invalid',
+ }
+
+ const { getByText } = render(
+
+
+
+
+
+ )
+
+ const errorField = getByText('favs invalid')
+ expect(errorField).not.toBeNull()
+ })
+})
diff --git a/wrappers/ts/chakra/v3/PasswordField.test.jsx b/wrappers/ts/chakra/v3/PasswordField.test.jsx
new file mode 100644
index 0000000..cb012f0
--- /dev/null
+++ b/wrappers/ts/chakra/v3/PasswordField.test.jsx
@@ -0,0 +1,54 @@
+import React from 'react'
+import { render } from '@testing-library/react'
+import { ChakraProvider, defaultSystem } from '@chakra-ui/react'
+import { PasswordField, ValidationContext } from '.'
+
+const buildPayload = () => {
+ return {
+ type: 'password',
+ name: 'post[password]',
+ id: 'post_password',
+ required: false,
+ defaultValue: 'Password123',
+ }
+}
+
+describe('PasswordField', () => {
+ it('renders', () => {
+ const payload = buildPayload()
+
+ const { getByLabelText } = render(
+
+
+
+ )
+
+ const input = getByLabelText('Password')
+ expect(input.required).toBeFalsy()
+ expect(input.value).toEqual('Password123')
+ expect(input.type).toEqual('password')
+ })
+
+ it('renders with field errors', async () => {
+ const payload = buildPayload()
+
+ const validationErrors = {
+ password: 'Does not match',
+ }
+
+ const { getByText } = render(
+
+
+
+
+
+ )
+
+ const errorField = getByText('Does not match')
+ expect(errorField).not.toBeNull()
+ })
+})
diff --git a/wrappers/ts/chakra/v3/RangeField.test.jsx b/wrappers/ts/chakra/v3/RangeField.test.jsx
new file mode 100644
index 0000000..b5d6865
--- /dev/null
+++ b/wrappers/ts/chakra/v3/RangeField.test.jsx
@@ -0,0 +1,64 @@
+import React from 'react'
+import { render } from '@testing-library/react'
+import { ChakraProvider, defaultSystem } from '@chakra-ui/react'
+import { RangeField, ValidationContext } from '.'
+
+const buildPayload = () => {
+ return {
+ type: 'range',
+ defaultValue: '2',
+ name: 'post[volume]',
+ min: 1,
+ max: 9,
+ id: 'post_volume',
+ step: 2,
+ }
+}
+
+describe('RangeField', () => {
+ it('renders', () => {
+ const payload = buildPayload()
+
+ const { getByRole } = render(
+
+
+
+ )
+
+ const slider = getByRole('slider', { hidden: true })
+ expect(slider).not.toBeNull()
+ })
+
+ it('renders a hidden input with the name', () => {
+ const payload = buildPayload()
+
+ const { container } = render(
+
+
+
+ )
+
+ const hiddenInput = container.querySelector('input[hidden]')
+ expect(hiddenInput).not.toBeNull()
+ expect(hiddenInput.name).toEqual('post[volume]')
+ })
+
+ it('renders with field errors', async () => {
+ const payload = buildPayload()
+
+ const validationErrors = {
+ volume: 'volume invalid',
+ }
+
+ const { getByText } = render(
+
+
+
+
+
+ )
+
+ const errorField = getByText('volume invalid')
+ expect(errorField).not.toBeNull()
+ })
+})
diff --git a/wrappers/ts/chakra/v3/SearchField.test.jsx b/wrappers/ts/chakra/v3/SearchField.test.jsx
new file mode 100644
index 0000000..80483ec
--- /dev/null
+++ b/wrappers/ts/chakra/v3/SearchField.test.jsx
@@ -0,0 +1,50 @@
+import React from 'react'
+import { render } from '@testing-library/react'
+import { ChakraProvider, defaultSystem } from '@chakra-ui/react'
+import { SearchField, ValidationContext } from '.'
+
+const buildPayload = () => {
+ return {
+ type: 'search',
+ name: 'post[query]',
+ id: 'post_query',
+ required: false,
+ defaultValue: 'hello',
+ }
+}
+
+describe('SearchField', () => {
+ it('renders', () => {
+ const payload = buildPayload()
+
+ const { getByLabelText } = render(
+
+
+
+ )
+
+ const input = getByLabelText('Query')
+ expect(input.required).toBeFalsy()
+ expect(input.value).toEqual('hello')
+ expect(input.type).toEqual('search')
+ })
+
+ it('renders with field errors', async () => {
+ const payload = buildPayload()
+
+ const validationErrors = {
+ query: 'query invalid',
+ }
+
+ const { getByText } = render(
+
+
+
+
+
+ )
+
+ const errorField = getByText('query invalid')
+ expect(errorField).not.toBeNull()
+ })
+})
diff --git a/wrappers/ts/chakra/v3/Select.test.jsx b/wrappers/ts/chakra/v3/Select.test.jsx
new file mode 100644
index 0000000..29ffd43
--- /dev/null
+++ b/wrappers/ts/chakra/v3/Select.test.jsx
@@ -0,0 +1,105 @@
+import React from 'react'
+import { render } from '@testing-library/react'
+import { ChakraProvider, defaultSystem } from '@chakra-ui/react'
+import { Select } from '.'
+
+const buildPayload = () => {
+ return {
+ type: 'select',
+ name: 'post[category]',
+ id: 'post_category',
+ defaultValue: '',
+ options: [
+ { value: 'abe', label: 'abe' },
+ { value: '', label: '' },
+ { value: 'hest', label: 'hest' },
+ ],
+ }
+}
+
+describe('Select', () => {
+ describe('rendering', () => {
+ it('adds a hidden input on multiple selects if includeHidden is true', () => {
+ const payload = buildPayload()
+ payload.multiple = true
+ payload.defaultValue = [payload.defaultValue]
+ payload.includeHidden = true
+
+ const { container } = render(
+
+
+
+ )
+
+ const hiddenInput = container.querySelector('input[type=hidden]')
+ expect(hiddenInput).not.toBe(null)
+ expect(hiddenInput.name).toEqual('post[category]')
+ })
+
+ it('excludes a hidden input on multiple selects if includeHidden is false', () => {
+ const payload = buildPayload()
+ payload.multiple = false
+ payload.includeHidden = false
+
+ const { container } = render(
+
+
+
+ )
+
+ const hiddenInput = container.querySelector('input[type=hidden]')
+ expect(hiddenInput).toBe(null)
+ })
+
+ it('renders the component with default value', async () => {
+ const payload = buildPayload()
+
+ const { getByText, container } = render(
+
+
+
+ )
+
+ const label = getByText('category')
+ expect(label).not.toBeNull()
+
+ const hiddenSelect = container.querySelector('select[name="post[category]"]')
+ expect(hiddenSelect).not.toBeNull()
+ expect(hiddenSelect.value).toEqual('')
+ })
+
+ it('renders with nested options', async () => {
+ const payload = {
+ type: 'select',
+ name: 'post[category]',
+ id: 'post_category',
+ includeHidden: true,
+ defaultValue: 'soccer',
+ options: [
+ { value: 'abe', label: 'abe' },
+ {
+ label: 'sports',
+ options: [
+ { value: 'soccer', label: 'Soccer' },
+ { value: 'baseball', label: 'Baseball' },
+ ],
+ },
+ { value: 'hest', label: 'hest' },
+ ],
+ }
+
+ const { getByText, container } = render(
+
+
+
+ )
+
+ const label = getByText('category')
+ expect(label).not.toBeNull()
+
+ const hiddenSelect = container.querySelector('select[name="post[category]"]')
+ expect(hiddenSelect).not.toBeNull()
+ expect(hiddenSelect.value).toEqual('soccer')
+ })
+ })
+})
diff --git a/wrappers/ts/chakra/v3/TelField.test.jsx b/wrappers/ts/chakra/v3/TelField.test.jsx
new file mode 100644
index 0000000..df61001
--- /dev/null
+++ b/wrappers/ts/chakra/v3/TelField.test.jsx
@@ -0,0 +1,50 @@
+import React from 'react'
+import { render } from '@testing-library/react'
+import { ChakraProvider, defaultSystem } from '@chakra-ui/react'
+import { TelField, ValidationContext } from '.'
+
+const buildPayload = () => {
+ return {
+ type: 'tel',
+ name: 'post[phone]',
+ id: 'post_phone',
+ required: false,
+ defaultValue: '555-1234',
+ }
+}
+
+describe('TelField', () => {
+ it('renders', () => {
+ const payload = buildPayload()
+
+ const { getByLabelText } = render(
+
+
+
+ )
+
+ const input = getByLabelText('Phone')
+ expect(input.required).toBeFalsy()
+ expect(input.value).toEqual('555-1234')
+ expect(input.type).toEqual('tel')
+ })
+
+ it('renders with field errors', async () => {
+ const payload = buildPayload()
+
+ const validationErrors = {
+ phone: 'phone invalid',
+ }
+
+ const { getByText } = render(
+
+
+
+
+
+ )
+
+ const errorField = getByText('phone invalid')
+ expect(errorField).not.toBeNull()
+ })
+})
diff --git a/wrappers/ts/chakra/v3/TextArea.test.jsx b/wrappers/ts/chakra/v3/TextArea.test.jsx
new file mode 100644
index 0000000..3ec8fdb
--- /dev/null
+++ b/wrappers/ts/chakra/v3/TextArea.test.jsx
@@ -0,0 +1,53 @@
+import React from 'react'
+import { render } from '@testing-library/react'
+import { ChakraProvider, defaultSystem } from '@chakra-ui/react'
+import { TextArea, ValidationContext } from '.'
+
+const buildPayload = () => {
+ return {
+ type: 'text',
+ name: 'post[category]',
+ id: 'post_category',
+ required: false,
+ defaultValue: 'books',
+ }
+}
+
+describe('TextArea', () => {
+ it('renders', () => {
+ const payload = buildPayload()
+
+ const { getByLabelText } = render(
+
+
+
+ )
+
+ const input = getByLabelText('category')
+ expect(input.required).toBeFalsy()
+ expect(input.value).toEqual('books')
+ expect(input.type).toEqual('textarea')
+ })
+
+ it('renders with field errors', async () => {
+ const payload = buildPayload()
+
+ const validationErrors = {
+ category: 'title invalid',
+ }
+
+ const { getByText, getByLabelText } = render(
+
+
+
+
+
+ )
+
+ const errorField = getByText('title invalid')
+ expect(errorField).not.toBeNull()
+
+ const input = getByLabelText('category')
+ expect(input.value).toEqual('books')
+ })
+})
diff --git a/wrappers/ts/chakra/v3/TextField.test.jsx b/wrappers/ts/chakra/v3/TextField.test.jsx
new file mode 100644
index 0000000..a89dd40
--- /dev/null
+++ b/wrappers/ts/chakra/v3/TextField.test.jsx
@@ -0,0 +1,53 @@
+import React from 'react'
+import { render } from '@testing-library/react'
+import { ChakraProvider, defaultSystem } from '@chakra-ui/react'
+import { TextField, ValidationContext } from '.'
+
+const buildPayload = () => {
+ return {
+ type: 'text',
+ name: 'post[category]',
+ id: 'post_category',
+ required: false,
+ defaultValue: 'books',
+ }
+}
+
+describe('TextField', () => {
+ it('renders', () => {
+ const payload = buildPayload()
+
+ const { getByLabelText } = render(
+
+
+
+ )
+
+ const input = getByLabelText('category')
+ expect(input.required).toBeFalsy()
+ expect(input.value).toEqual('books')
+ expect(input.type).toEqual('text')
+ })
+
+ it('renders with field errors', async () => {
+ const payload = buildPayload()
+
+ const validationErrors = {
+ category: 'title invalid',
+ }
+
+ const { getByText, getByLabelText } = render(
+
+
+
+
+
+ )
+
+ const errorField = getByText('title invalid')
+ expect(errorField).not.toBeNull()
+
+ const input = getByLabelText('category')
+ expect(input.value).toEqual('books')
+ })
+})
diff --git a/wrappers/ts/chakra/v3/TimeField.test.jsx b/wrappers/ts/chakra/v3/TimeField.test.jsx
new file mode 100644
index 0000000..c21efb1
--- /dev/null
+++ b/wrappers/ts/chakra/v3/TimeField.test.jsx
@@ -0,0 +1,54 @@
+import React from 'react'
+import { render } from '@testing-library/react'
+import { ChakraProvider, defaultSystem } from '@chakra-ui/react'
+import { TimeField, ValidationContext } from '.'
+
+const buildPayload = () => {
+ return {
+ type: 'time',
+ name: 'post[start_time]',
+ id: 'post_start_time',
+ required: false,
+ defaultValue: '14:30',
+ }
+}
+
+describe('TimeField', () => {
+ it('renders', () => {
+ const payload = buildPayload()
+
+ const { getByLabelText } = render(
+
+
+
+ )
+
+ const input = getByLabelText('Start time')
+ expect(input.required).toBeFalsy()
+ expect(input.value).toEqual('14:30')
+ expect(input.type).toEqual('time')
+ })
+
+ it('renders with field errors', async () => {
+ const payload = buildPayload()
+
+ const validationErrors = {
+ start_time: 'time invalid',
+ }
+
+ const { getByText } = render(
+
+
+
+
+
+ )
+
+ const errorField = getByText('time invalid')
+ expect(errorField).not.toBeNull()
+ })
+})
diff --git a/wrappers/ts/chakra/v3/UrlField.test.jsx b/wrappers/ts/chakra/v3/UrlField.test.jsx
new file mode 100644
index 0000000..d35a3dd
--- /dev/null
+++ b/wrappers/ts/chakra/v3/UrlField.test.jsx
@@ -0,0 +1,50 @@
+import React from 'react'
+import { render } from '@testing-library/react'
+import { ChakraProvider, defaultSystem } from '@chakra-ui/react'
+import { UrlField, ValidationContext } from '.'
+
+const buildPayload = () => {
+ return {
+ type: 'url',
+ name: 'post[website]',
+ id: 'post_website',
+ required: false,
+ defaultValue: 'https://example.com',
+ }
+}
+
+describe('UrlField', () => {
+ it('renders', () => {
+ const payload = buildPayload()
+
+ const { getByLabelText } = render(
+
+
+
+ )
+
+ const input = getByLabelText('Website')
+ expect(input.required).toBeFalsy()
+ expect(input.value).toEqual('https://example.com')
+ expect(input.type).toEqual('url')
+ })
+
+ it('renders with field errors', async () => {
+ const payload = buildPayload()
+
+ const validationErrors = {
+ website: 'url invalid',
+ }
+
+ const { getByText } = render(
+
+
+
+
+
+ )
+
+ const errorField = getByText('url invalid')
+ expect(errorField).not.toBeNull()
+ })
+})
diff --git a/wrappers/ts/chakra/v3/components/ui/password-input.tsx b/wrappers/ts/chakra/v3/components/ui/password-input.tsx
new file mode 100644
index 0000000..3b9af80
--- /dev/null
+++ b/wrappers/ts/chakra/v3/components/ui/password-input.tsx
@@ -0,0 +1,159 @@
+"use client"
+
+import type {
+ ButtonProps,
+ GroupProps,
+ InputProps,
+ StackProps,
+} from "@chakra-ui/react"
+import {
+ Box,
+ HStack,
+ IconButton,
+ Input,
+ InputGroup,
+ Stack,
+ mergeRefs,
+ useControllableState,
+} from "@chakra-ui/react"
+import * as React from "react"
+import { LuEye, LuEyeOff } from "react-icons/lu"
+
+export interface PasswordVisibilityProps {
+ /**
+ * The default visibility state of the password input.
+ */
+ defaultVisible?: boolean
+ /**
+ * The controlled visibility state of the password input.
+ */
+ visible?: boolean
+ /**
+ * Callback invoked when the visibility state changes.
+ */
+ onVisibleChange?: (visible: boolean) => void
+ /**
+ * Custom icons for the visibility toggle button.
+ */
+ visibilityIcon?: { on: React.ReactNode; off: React.ReactNode }
+}
+
+export interface PasswordInputProps
+ extends InputProps,
+ PasswordVisibilityProps {
+ rootProps?: GroupProps
+}
+
+export const PasswordInput = React.forwardRef<
+ HTMLInputElement,
+ PasswordInputProps
+>(function PasswordInput(props, ref) {
+ const {
+ rootProps,
+ defaultVisible,
+ visible: visibleProp,
+ onVisibleChange,
+ visibilityIcon = { on: , off: },
+ ...rest
+ } = props
+
+ const [visible, setVisible] = useControllableState({
+ value: visibleProp,
+ defaultValue: defaultVisible || false,
+ onChange: onVisibleChange,
+ })
+
+ const inputRef = React.useRef(null)
+
+ return (
+ {
+ if (rest.disabled) return
+ if (e.button !== 0) return
+ e.preventDefault()
+ setVisible(!visible)
+ }}
+ >
+ {visible ? visibilityIcon.off : visibilityIcon.on}
+
+ }
+ {...rootProps}
+ >
+
+
+ )
+})
+
+const VisibilityTrigger = React.forwardRef(
+ function VisibilityTrigger(props, ref) {
+ return (
+
+ )
+ },
+)
+
+interface PasswordStrengthMeterProps extends StackProps {
+ max?: number
+ value: number
+}
+
+export const PasswordStrengthMeter = React.forwardRef<
+ HTMLDivElement,
+ PasswordStrengthMeterProps
+>(function PasswordStrengthMeter(props, ref) {
+ const { max = 4, value, ...rest } = props
+
+ const percent = (value / max) * 100
+ const { label, colorPalette } = getColorPalette(percent)
+
+ return (
+
+
+ {Array.from({ length: max }).map((_, index) => (
+
+ ))}
+
+ {label && {label}}
+
+ )
+})
+
+function getColorPalette(percent: number) {
+ switch (true) {
+ case percent < 33:
+ return { label: "Low", colorPalette: "red" }
+ case percent < 66:
+ return { label: "Medium", colorPalette: "orange" }
+ default:
+ return { label: "High", colorPalette: "green" }
+ }
+}
diff --git a/wrappers/ts/chakra/v3/index.tsx b/wrappers/ts/chakra/v3/index.tsx
new file mode 100644
index 0000000..01879bd
--- /dev/null
+++ b/wrappers/ts/chakra/v3/index.tsx
@@ -0,0 +1,755 @@
+import React, { createContext, ReactNode, useContext, useMemo } from 'react'
+
+export type {
+ CheckboxField as RailsCheckboxFieldProps,
+ CollectionCheckboxesField as RailsCollectionCheckboxesFieldProps,
+ CollectionRadioButtonsField as RailsCollectionRadioButtonsFieldProps,
+ ColorField as RailsColorFieldProps,
+ DateField as RailsDateFieldProps,
+ DateTimeLocalField as RailsDateTimeLocalFieldProps,
+ EmailField as RailsEmailFieldProps,
+ FileField as RailsFileFieldProps,
+ HiddenField as RailsHiddenFieldProps,
+ MonthField as RailsMonthFieldProps,
+ NumberField as RailsNumberFieldProps,
+ PasswordField as RailsPasswordFieldProps,
+ RangeField as RailsRangeFieldProps,
+ SearchField as RailsSearchFieldProps,
+ Select as RailsSelectProps,
+ SubmitProps as RailsSubmitButtonProps,
+ TelField as RailsTelFieldProps,
+ TextArea as RailsTextAreaProps,
+ TextField as RailsTextFieldProps,
+ TimeField as RailsTimeFieldProps,
+ UrlField as RailsUrlFieldProps,
+ HTMLFormProps as RailsHTMLFormProps,
+ ValidationErrors,
+} from '@thoughtbot/candy_wrapper'
+
+import {
+ CheckboxField as RailsCheckboxField,
+ CollectionCheckboxesField as RailsCollectionCheckboxesField,
+ CollectionRadioButtonsField as RailsCollectionRadioButtonsField,
+ ColorField as RailsColorField,
+ DateField as RailsDateField,
+ DateTimeLocalField as RailsDateTimeLocalField,
+ EmailField as RailsEmailField,
+ FileField as RailsFileField,
+ HiddenField as RailsHiddenField,
+ MonthField as RailsMonthField,
+ NumberField as RailsNumberField,
+ PasswordField as RailsPasswordField,
+ RangeField as RailsRangeField,
+ SearchField as RailsSearchField,
+ SingleSelect as RailsSingleSelect,
+ MultiSelect as RailsMultiSelect,
+ SubmitProps as RailsSubmitButton,
+ TelField as RailsTelField,
+ TextArea as RailsTextArea,
+ TextField as RailsTextField,
+ TimeField as RailsTimeField,
+ UrlField as RailsUrlField,
+ HTMLFormProps as RailsHTMLFormProps,
+ ValidationErrors,
+} from '@thoughtbot/candy_wrapper'
+
+import {
+ Input as ChakraInput,
+ Textarea as ChakraTextarea,
+ FileUpload as ChakraFileUpload,
+ ColorPicker as ChakraColorPicker,
+ Checkbox as ChakraCheckbox,
+ CheckboxGroup as ChakraCheckboxGroup,
+ RadioGroup as ChakraRadioGroup,
+ Select as ChakraSelect,
+ Fieldset as ChakraFieldset,
+ NumberInput as ChakraNumberInput,
+ Slider as ChakraSlider,
+ Field as ChakraField,
+ Button as ChakraButton,
+ Portal,
+ createListCollection,
+ parseColor,
+} from '@chakra-ui/react'
+import { PasswordInput as ChakraPasswordInput } from './components/ui/password-input'
+
+export const ValidationContext = createContext({})
+
+export const useErrorMessage = (errorKey?: string) => {
+ const errors = useContext(ValidationContext)
+
+ return useMemo(() => {
+ if (!errorKey) {
+ return null
+ }
+
+ const validationError = errors[errorKey]
+ const hasErrors = errorKey && validationError
+
+ if (!hasErrors) {
+ return null
+ }
+
+ const errorMessages = Array.isArray(validationError)
+ ? validationError
+ : [validationError]
+
+ return errorMessages.join(' ')
+ }, [errors, errorKey])
+}
+
+export type ExtrasProps = Record
+
+export const Extras = (hiddenInputAttributes: ExtrasProps) => {
+ const hiddenProps = Object.values(hiddenInputAttributes)
+ const hiddenInputs = hiddenProps.map((props: RailsHiddenField) => (
+
+ ))
+
+ return <>{hiddenInputs}>
+}
+
+export interface FormProps {
+ extras: ExtrasProps
+ inputs: T
+ form: RailsHTMLFormProps
+}
+
+type FormElementProps = React.FormHTMLAttributes & {
+ extras: ExtrasProps
+ validationErrors?: ValidationErrors
+}
+
+export const Form = ({
+ extras,
+ validationErrors = {},
+ children,
+ ...props
+}: FormElementProps) => {
+ return (
+
+ )
+}
+
+type InputProps = {
+ label: string
+ errorKey?: string
+}
+
+type ChakraFieldWrapperProps = {
+ label: string
+ errorKey?: string
+ id?: string
+ children: ReactNode
+}
+
+const ChakraFieldWrapper = ({
+ label,
+ errorKey,
+ id,
+ children,
+}: ChakraFieldWrapperProps) => {
+ const errorMessage = useErrorMessage(errorKey)
+
+ return (
+
+ {label}
+ {children}
+ {errorMessage && (
+ {errorMessage}
+ )}
+
+ )
+}
+
+type CheckboxProps = RailsCheckboxField & InputProps
+
+export const Checkbox = ({
+ type: _type,
+ includeHidden,
+ uncheckedValue,
+ errorKey,
+ label,
+ ...rest
+}: CheckboxProps) => {
+ const { name } = rest
+ const errorMessage = useErrorMessage(errorKey)
+
+ return (
+
+ {includeHidden && (
+
+ )}
+
+
+
+
+
+ {label}
+
+ {errorMessage && (
+ {errorMessage}
+ )}
+
+ )
+}
+
+type CollectionCheckboxesFieldProps = RailsCollectionCheckboxesField &
+ InputProps
+
+export const CollectionCheckboxes = ({
+ includeHidden,
+ collection,
+ label,
+ errorKey,
+}: CollectionCheckboxesFieldProps) => {
+ const errorMessage = useErrorMessage(errorKey)
+
+ if (collection.length === 0) {
+ return null
+ }
+
+ const { name } = collection[0]
+
+ const defaultItems = collection.filter((option) => !!option.defaultChecked)
+ const checkedItems = collection.filter((option) => !!option.checked)
+ const valueProps: { defaultValue?: string[]; value?: string[] } = {}
+ if (defaultItems.length > 0) {
+ valueProps.defaultValue = defaultItems.map((option) => option.value)
+ } else if (checkedItems.length > 0) {
+ valueProps.value = checkedItems.map((option) => option.value)
+ }
+
+ return (
+
+ {includeHidden && (
+
+ )}
+
+
+ {label}
+
+
+ {collection.map((option) => (
+
+
+
+
+
+ {option.label}
+
+ ))}
+
+
+ {errorMessage && (
+ {errorMessage}
+ )}
+
+ )
+}
+
+type CollectionRadioButtonsFieldProps = RailsCollectionRadioButtonsField &
+ InputProps
+
+export const CollectionRadioButtons = ({
+ includeHidden,
+ collection,
+ label,
+ errorKey,
+}: CollectionRadioButtonsFieldProps) => {
+ const errorMessage = useErrorMessage(errorKey)
+
+ if (collection.length === 0) {
+ return null
+ }
+
+ const defaultItem = collection.find((option) => !!option.defaultChecked)
+ const item = collection.find((option) => !!option.checked)
+ const valueProps: { defaultValue?: string; value?: string } = {}
+ if (defaultItem) {
+ valueProps.defaultValue = defaultItem.value
+ } else if (item) {
+ valueProps.value = item.value
+ }
+
+ const radioButtons = collection.map((options) => {
+ const {
+ checked: _checked,
+ defaultChecked: _defaultChecked,
+ type: _type,
+ label: itemLabel,
+ ...rest
+ } = options
+ return (
+
+
+
+ {itemLabel}
+
+ )
+ })
+
+ const { name } = collection[0]
+
+ return (
+
+ {includeHidden && (
+
+ )}
+
+
+ {label}
+
+
+ {radioButtons}
+
+
+ {errorMessage && (
+ {errorMessage}
+ )}
+
+ )
+}
+
+export type TextFieldProps = RailsTextField & InputProps
+
+export const TextField = ({
+ type: _type,
+ label,
+ errorKey,
+ size: _size,
+ ...rest
+}: TextFieldProps) => {
+ return (
+
+
+
+ )
+}
+
+export type EmailFieldProps = RailsEmailField & InputProps
+
+export const EmailField = ({
+ type: _type,
+ label,
+ errorKey,
+ size: _size,
+ ...rest
+}: EmailFieldProps) => {
+ return (
+
+
+
+ )
+}
+
+export type ColorFieldProps = RailsColorField & InputProps
+
+export const ColorField = ({
+ type: _type,
+ label,
+ errorKey,
+ value,
+ defaultValue,
+ ...rest
+}: ColorFieldProps) => {
+ const errorMessage = useErrorMessage(errorKey)
+ const colorValue = parseColor(value || defaultValue || '#000000')
+
+ return (
+
+
+
+ {label}
+
+
+
+
+
+
+
+
+
+
+
+
+ {errorMessage && (
+ {errorMessage}
+ )}
+
+ )
+}
+
+export type DateFieldProps = RailsDateField & InputProps
+
+export const DateField = ({
+ type: _type,
+ label,
+ errorKey,
+ ...rest
+}: DateFieldProps) => {
+ return (
+
+
+
+ )
+}
+
+export type DateTimeLocalFieldProps = RailsDateTimeLocalField & InputProps
+
+export const DateTimeLocalField = ({
+ type: _type,
+ label,
+ errorKey,
+ ...rest
+}: DateTimeLocalFieldProps) => {
+ return (
+
+
+
+ )
+}
+
+export type SearchFieldProps = RailsSearchField & InputProps
+
+export const SearchField = ({
+ type: _type,
+ label,
+ errorKey,
+ size: _size,
+ ...rest
+}: SearchFieldProps) => {
+ return (
+
+
+
+ )
+}
+
+export type TelFieldProps = RailsTelField & InputProps
+
+export const TelField = ({
+ type: _type,
+ label,
+ errorKey,
+ size: _size,
+ ...rest
+}: TelFieldProps) => {
+ return (
+
+
+
+ )
+}
+
+export type UrlFieldProps = RailsUrlField & InputProps
+
+export const UrlField = ({
+ type: _type,
+ label,
+ errorKey,
+ size: _size,
+ ...rest
+}: UrlFieldProps) => {
+ return (
+
+
+
+ )
+}
+
+export type MonthFieldProps = RailsMonthField & InputProps
+
+export const MonthField = ({
+ type: _type,
+ label,
+ errorKey,
+ ...rest
+}: MonthFieldProps) => {
+ return (
+
+
+
+ )
+}
+
+export type TimeFieldProps = RailsTimeField & InputProps
+
+export const TimeField = ({
+ type: _type,
+ label,
+ errorKey,
+ ...rest
+}: TimeFieldProps) => {
+ return (
+
+
+
+ )
+}
+
+export type NumberFieldProps = RailsNumberField & InputProps
+
+export const NumberField = ({
+ type: _type,
+ label,
+ errorKey,
+ ...rest
+}: NumberFieldProps) => {
+ return (
+
+
+
+
+
+
+
+
+
+ )
+}
+
+export type RangeFieldProps = RailsRangeField & InputProps
+
+export const RangeField = ({
+ type: _type,
+ label,
+ errorKey,
+ defaultValue,
+ value,
+ ...rest
+}: RangeFieldProps) => {
+ const errorMessage = useErrorMessage(errorKey)
+ const nextDefaultValue = defaultValue ? [Number(defaultValue)] : undefined
+ const nextValue = value ? [Number(value)] : undefined
+
+ return (
+
+
+ {label}
+
+
+
+
+
+
+
+ {errorMessage && (
+ {errorMessage}
+ )}
+
+ )
+}
+
+export type PasswordFieldProps = RailsPasswordField & InputProps
+
+export const PasswordField = ({
+ type: _type,
+ label,
+ errorKey,
+ size: _size,
+ ...rest
+}: PasswordFieldProps) => {
+ return (
+
+
+
+ )
+}
+
+type SelectProps = (RailsSingleSelect | RailsMultiSelect) & InputProps
+
+export const Select = ({
+ includeHidden,
+ name,
+ id,
+ options,
+ label,
+ errorKey,
+ multiple,
+ type: _type,
+ defaultValue,
+ value,
+ ...rest
+}: SelectProps) => {
+ const errorMessage = useErrorMessage(errorKey)
+ const addHidden = includeHidden && multiple
+
+ const nextOptions = options.slice()
+ const firstOption = nextOptions[0]
+ let placeholder = 'Select...'
+
+ if (firstOption && !('options' in firstOption) && firstOption.value === '') {
+ placeholder = firstOption.label
+ nextOptions.shift()
+ }
+
+ const flatItems = nextOptions.flatMap((item) => {
+ if ('options' in item) {
+ return item.options
+ }
+ return [item]
+ })
+
+ const collection = createListCollection({ items: flatItems })
+
+ const defaultValueArray = defaultValue
+ ? Array.isArray(defaultValue)
+ ? defaultValue
+ : [defaultValue]
+ : undefined
+
+ const valueArray = value
+ ? Array.isArray(value)
+ ? value
+ : [value]
+ : undefined
+
+ return (
+
+ {addHidden && (
+
+ )}
+
+
+ {label}
+
+
+
+
+
+
+
+
+
+
+
+ {nextOptions.map((item) => {
+ if ('options' in item) {
+ return (
+
+
+ {item.label}
+
+ {item.options.map((opt) => (
+
+ {opt.label}
+
+
+ ))}
+
+ )
+ }
+ return (
+
+ {item.label}
+
+
+ )
+ })}
+
+
+
+
+ {errorMessage && (
+ {errorMessage}
+ )}
+
+ )
+}
+
+export type TextAreaProps = RailsTextArea & InputProps
+
+export const TextArea = ({
+ type: _type,
+ label,
+ errorKey,
+ ...rest
+}: TextAreaProps) => {
+ return (
+
+
+
+ )
+}
+
+export type FileFieldProps = RailsFileField & InputProps
+
+export const FileField = ({
+ type: _type,
+ label,
+ errorKey,
+ ...rest
+}: FileFieldProps) => {
+ const errorMessage = useErrorMessage(errorKey)
+
+ return (
+
+
+
+ {label}
+
+ Choose file
+
+
+
+ {({ acceptedFiles }) =>
+ acceptedFiles.map((file) => (
+
+
+
+
+
+ ))
+ }
+
+
+
+ {errorMessage && (
+ {errorMessage}
+ )}
+
+ )
+}
+
+type SubmitButtonProps = RailsSubmitButton
+
+export const SubmitButton = ({
+ type: _type,
+ text,
+ ...rest
+}: SubmitButtonProps) => {
+ return (
+
+ {text}
+
+ )
+}
diff --git a/wrappers/ts/chakra/v3/package.json b/wrappers/ts/chakra/v3/package.json
new file mode 100644
index 0000000..86bd92c
--- /dev/null
+++ b/wrappers/ts/chakra/v3/package.json
@@ -0,0 +1,12 @@
+{
+ "name": "@candy_wrapper/chakra-v3",
+ "private": true,
+ "scripts": {
+ "typecheck": "tsc --noEmit",
+ "build": "tsc --project tsconfig.build.json && mkdir -p ../../../../wrappers/js/chakra/v3/components/ui && cp dist/wrappers/ts/chakra/v3/index.jsx ../../../../wrappers/js/chakra/v3/index.jsx && cp dist/wrappers/ts/chakra/v3/components/ui/password-input.jsx ../../../../wrappers/js/chakra/v3/components/ui/password-input.jsx && rm -rf dist"
+ },
+ "devDependencies": {
+ "@chakra-ui/react": "^3.0.0",
+ "react-icons": "^5.7.0"
+ }
+}
diff --git a/wrappers/ts/chakra/v3/tsconfig.build.json b/wrappers/ts/chakra/v3/tsconfig.build.json
new file mode 100644
index 0000000..44a26fa
--- /dev/null
+++ b/wrappers/ts/chakra/v3/tsconfig.build.json
@@ -0,0 +1,14 @@
+{
+ "extends": "../../../../tsconfig",
+ "compilerOptions": {
+ "removeComments": false,
+ "declaration": false,
+ "moduleResolution": "bundler",
+ "skipLibCheck": true,
+ "outDir": "./dist",
+ "paths": {
+ "@thoughtbot/candy_wrapper": ["../../../../src/index"]
+ }
+ },
+ "include": ["./index.tsx", "./components/**/*.tsx"]
+}
diff --git a/wrappers/ts/chakra/v3/tsconfig.json b/wrappers/ts/chakra/v3/tsconfig.json
new file mode 100644
index 0000000..0f669f6
--- /dev/null
+++ b/wrappers/ts/chakra/v3/tsconfig.json
@@ -0,0 +1,12 @@
+{
+ "extends": "../../../../tsconfig",
+ "compilerOptions": {
+ "noEmit": true,
+ "moduleResolution": "bundler",
+ "skipLibCheck": true,
+ "paths": {
+ "@thoughtbot/candy_wrapper": ["../../../../src/index"]
+ }
+ },
+ "include": ["./**/*.ts", "./**/*.tsx"]
+}
diff --git a/wrappers/ts/heroui/v3/index.tsx b/wrappers/ts/heroui/v3/index.tsx
new file mode 100644
index 0000000..c414156
--- /dev/null
+++ b/wrappers/ts/heroui/v3/index.tsx
@@ -0,0 +1,840 @@
+/**
+ * A set of [candy_wrappers](https://github.com/thoughtbot/candy_wrapper) around
+ * HeroUI v3 input components. It works with the output from
+ * [FormProps](https://github.com/thoughtbot/form_props).
+ *
+ * You modify these components to fit your design needs.
+ */
+
+import React, { createContext, useContext, useMemo, useState } from 'react'
+import {
+ parseDate,
+ parseDateTime,
+ parseTime,
+ CalendarDate,
+ CalendarDateTime,
+ Time,
+} from '@internationalized/date'
+
+export type {
+ CheckboxField as RailsCheckboxFieldProps,
+ CollectionCheckboxesField as RailsCollectionCheckboxesFieldProps,
+ CollectionRadioButtonsField as RailsCollectionRadioButtonsFieldProps,
+ ColorField as RailsColorFieldProps,
+ DateField as RailsDateFieldProps,
+ DateTimeLocalField as RailsDateTimeLocalFieldProps,
+ EmailField as RailsEmailFieldProps,
+ FileField as RailsFileFieldProps,
+ HiddenField as RailsHiddenFieldProps,
+ MonthField as RailsMonthFieldProps,
+ NumberField as RailsNumberFieldProps,
+ PasswordField as RailsPasswordFieldProps,
+ RangeField as RailsRangeFieldProps,
+ SearchField as RailsSearchFieldProps,
+ Select as RailsSelectProps,
+ SubmitProps as RailsSubmitButtonProps,
+ TelField as RailsTelFieldProps,
+ TextArea as RailsTextAreaProps,
+ TextField as RailsTextFieldProps,
+ TimeField as RailsTimeFieldProps,
+ UrlField as RailsUrlFieldProps,
+ HTMLFormProps as RailsHTMLFormProps,
+ ValidationErrors,
+} from '@thoughtbot/candy_wrapper'
+
+import {
+ CheckboxField as RailsCheckboxField,
+ CollectionCheckboxesField as RailsCollectionCheckboxesField,
+ CollectionRadioButtonsField as RailsCollectionRadioButtonsField,
+ ColorField as RailsColorField,
+ DateField as RailsDateField,
+ DateTimeLocalField as RailsDateTimeLocalField,
+ EmailField as RailsEmailField,
+ FileField as RailsFileField,
+ HiddenField as RailsHiddenField,
+ MonthField as RailsMonthField,
+ NumberField as RailsNumberField,
+ PasswordField as RailsPasswordField,
+ RangeField as RailsRangeField,
+ SearchField as RailsSearchField,
+ SingleSelect as RailsSingleSelect,
+ MultiSelect as RailsMultiSelect,
+ SubmitProps as RailsSubmitButton,
+ TelField as RailsTelField,
+ TextArea as RailsTextArea,
+ TextField as RailsTextField,
+ TimeField as RailsTimeField,
+ UrlField as RailsUrlField,
+ HTMLFormProps as RailsHTMLFormProps,
+ ValidationErrors,
+} from '@thoughtbot/candy_wrapper'
+
+import {
+ TextField as HeroTextField,
+ Header as HeroHeader,
+ Input as HeroInput,
+ Label as HeroLabel,
+ FieldError as HeroFieldError,
+ TextArea as HeroTextArea,
+ Checkbox as HeroCheckbox,
+ CheckboxGroup as HeroCheckboxGroup,
+ RadioGroup as HeroRadioGroup,
+ Radio as HeroRadio,
+ Select as HeroSelect,
+ ListBox as HeroListBox,
+ NumberField as HeroNumberField,
+ SearchField as HeroSearchField,
+ DateField as HeroDateField,
+ DatePicker as HeroDatePicker,
+ TimeField as HeroTimeField,
+ Slider as HeroSlider,
+ Button as HeroButton,
+ ColorField as HeroColorField,
+ ColorSwatch as HeroColorSwatch,
+ ErrorMessage as HeroErrorMessage,
+ parseColor,
+} from '@heroui/react'
+import type { Color } from '@heroui/react'
+
+export const ValidationContext = createContext({})
+
+export const useErrorMessage = (errorKey?: string) => {
+ const errors = useContext(ValidationContext)
+
+ return useMemo(() => {
+ if (!errorKey) {
+ return null
+ }
+
+ const validationError = errors[errorKey]
+ const hasErrors = errorKey && validationError
+
+ if (!hasErrors) {
+ return null
+ }
+
+ const errorMessages = Array.isArray(validationError)
+ ? validationError
+ : [validationError]
+
+ return errorMessages.join(' ')
+ }, [errors, errorKey])
+}
+
+export type ExtrasProps = Record
+
+export const Extras = (hiddenInputAttributes: ExtrasProps) => {
+ const hiddenProps = Object.values(hiddenInputAttributes)
+ const hiddenInputs = hiddenProps.map((props: RailsHiddenField) => (
+
+ ))
+
+ return <>{hiddenInputs}>
+}
+
+export interface FormProps {
+ extras: ExtrasProps
+ inputs: T
+ form: RailsHTMLFormProps
+}
+
+type FormElementProps = React.FormHTMLAttributes & {
+ extras: ExtrasProps
+ validationErrors?: ValidationErrors
+}
+
+export const Form = ({
+ extras,
+ validationErrors = {},
+ children,
+ ...props
+}: FormElementProps) => {
+ return (
+
+ )
+}
+
+type InputProps = {
+ label: string
+ errorKey?: string
+}
+
+// -- Transform functions --
+
+export const checkboxPropsToHeroProps = (
+ props: Partial
+): Partial> => {
+ const { defaultChecked, checked, type: _type, ...rest } = props
+ const heroProps: Partial> = {
+ ...rest,
+ }
+
+ if (defaultChecked !== undefined) {
+ heroProps.defaultSelected = defaultChecked
+ }
+ if (checked !== undefined) {
+ heroProps.isSelected = checked
+ }
+
+ return heroProps
+}
+
+type RailsTextLikeField =
+ | RailsTextField
+ | RailsEmailField
+ | RailsPasswordField
+ | RailsSearchField
+ | RailsTelField
+ | RailsUrlField
+
+export const textFieldToHeroProps = (props: Partial) => {
+ const { type: _type, placeholder, ...fieldProps } = props
+ return { fieldProps, inputProps: { placeholder } }
+}
+
+export const numberFieldToHeroProps = (props: Partial) => {
+ const { value, defaultValue, min, max, type: _type, ...rest } = props
+ const heroProps: Record = { ...rest }
+
+ if (value !== undefined && value !== '') {
+ heroProps.value = Number(value)
+ }
+ if (defaultValue !== undefined && defaultValue !== '') {
+ heroProps.defaultValue = Number(defaultValue)
+ }
+ if (min !== undefined) {
+ heroProps.minValue = min
+ }
+ if (max !== undefined) {
+ heroProps.maxValue = max
+ }
+
+ return heroProps
+}
+
+export const dateFieldToHeroProps = (props: Partial) => {
+ const { max, min, value, defaultValue, type: _type, ...rest } = props
+ const heroProps: Record = { ...rest }
+
+ if (max) heroProps.maxValue = parseDate(max)
+ if (min) heroProps.minValue = parseDate(min)
+ if (defaultValue) heroProps.defaultValue = parseDate(defaultValue)
+ if (value) heroProps.value = parseDate(value)
+
+ return heroProps
+}
+
+export const dateTimeLocalFieldToHeroProps = (
+ props: Partial
+) => {
+ const { max, min, value, defaultValue, type: _type, ...rest } = props
+ const heroProps: Record = { ...rest }
+ heroProps.granularity = 'second'
+
+ if (max) heroProps.maxValue = parseDateTime(max)
+ if (min) heroProps.minValue = parseDateTime(min)
+ if (defaultValue) heroProps.defaultValue = parseDateTime(defaultValue)
+ if (value) heroProps.value = parseDateTime(value)
+
+ return heroProps
+}
+
+export const timeFieldToHeroProps = (props: Partial) => {
+ const { min, max, value, defaultValue, type: _type, ...rest } = props
+ const heroProps: Record = { ...rest }
+
+ if (value) heroProps.value = parseTime(value)
+ if (defaultValue) heroProps.defaultValue = parseTime(defaultValue)
+ if (min) heroProps.minValue = parseTime(min)
+ if (max) heroProps.maxValue = parseTime(max)
+
+ return heroProps
+}
+
+// -- Components --
+
+type CheckboxProps = RailsCheckboxField & InputProps
+
+export const Checkbox = ({
+ includeHidden,
+ uncheckedValue,
+ errorKey,
+ label,
+ ...rest
+}: CheckboxProps) => {
+ const heroProps = checkboxPropsToHeroProps(rest)
+ const { name } = heroProps
+ const errorMessage = useErrorMessage(errorKey)
+
+ return (
+ <>
+ {includeHidden && (
+
+ )}
+
+
+
+
+
+ {label}
+
+
+ >
+ )
+}
+
+type CollectionCheckboxesFieldProps = RailsCollectionCheckboxesField &
+ InputProps
+
+export const CollectionCheckboxes = ({
+ includeHidden,
+ collection,
+ label,
+ errorKey,
+}: CollectionCheckboxesFieldProps) => {
+ const errorMessage = useErrorMessage(errorKey)
+
+ if (collection.length === 0) {
+ return null
+ }
+
+ const defaultItems = collection.filter((option) => !!option.defaultChecked)
+ const items = collection.filter((option) => !!option.checked)
+ const valueProps: { value?: string[]; defaultValue?: string[] } = {}
+ if (defaultItems.length > 0) {
+ valueProps.defaultValue = defaultItems.map((option) => option.value)
+ } else if (items.length > 0) {
+ valueProps.value = items.map((option) => option.value)
+ }
+
+ const { name } = collection[0]
+
+ return (
+ <>
+ {includeHidden && (
+
+ )}
+
+ {label}
+ {collection.map((option) => (
+
+
+
+
+
+ {option.label}
+
+
+ ))}
+ {errorMessage && {errorMessage}}
+
+ >
+ )
+}
+
+type CollectionRadioButtonsFieldProps = RailsCollectionRadioButtonsField &
+ InputProps
+
+export const CollectionRadioButtons = ({
+ includeHidden,
+ collection,
+ label,
+ errorKey,
+}: CollectionRadioButtonsFieldProps) => {
+ const errorMessage = useErrorMessage(errorKey)
+
+ if (collection.length === 0) {
+ return null
+ }
+
+ const defaultItem = collection.find((option) => !!option.defaultChecked)
+ const item = collection.find((option) => !!option.checked)
+ const valueProps: { value?: string; defaultValue?: string } = {}
+ if (defaultItem) {
+ valueProps.defaultValue = defaultItem.value
+ } else if (item) {
+ valueProps.value = item.value
+ }
+
+ const { name } = collection[0]
+
+ return (
+ <>
+ {includeHidden && (
+
+ )}
+
+ {label}
+ {collection.map((option) => (
+
+
+
+
+
+ {option.label}
+
+
+ ))}
+ {errorMessage && {errorMessage}}
+
+ >
+ )
+}
+
+export type TextFieldProps = RailsTextField & InputProps
+
+export const TextField = ({ label, errorKey, ...rest }: TextFieldProps) => {
+ const { fieldProps, inputProps } = textFieldToHeroProps(rest)
+ const errorMessage = useErrorMessage(errorKey)
+
+ return (
+
+ {label}
+
+ {errorMessage && {errorMessage}}
+
+ )
+}
+
+export type EmailFieldProps = RailsEmailField & InputProps
+
+export const EmailField = ({ label, errorKey, ...rest }: EmailFieldProps) => {
+ const { fieldProps, inputProps } = textFieldToHeroProps(rest)
+ const errorMessage = useErrorMessage(errorKey)
+
+ return (
+
+ {label}
+
+ {errorMessage && {errorMessage}}
+
+ )
+}
+
+export type ColorFieldProps = RailsColorField & InputProps
+
+export const ColorField = ({
+ type: _type,
+ label,
+ errorKey,
+ ...rest
+}: ColorFieldProps) => {
+ const errorMessage = useErrorMessage(errorKey)
+ const initialColor = rest.value || rest.defaultValue
+ const [color, setColor] = useState(
+ initialColor ? parseColor(initialColor) : null
+ )
+
+ return (
+
+ {label}
+
+
+
+
+
+
+ {errorMessage && {errorMessage}}
+
+ )
+}
+
+export type DateFieldProps = RailsDateField & InputProps
+
+export const DateField = ({ label, errorKey, ...rest }: DateFieldProps) => {
+ const heroProps = dateFieldToHeroProps(rest)
+ const errorMessage = useErrorMessage(errorKey)
+
+ return (
+
+ {label}
+
+
+ {(segment) => }
+
+
+ {errorMessage && {errorMessage}}
+
+ )
+}
+
+export type DateTimeLocalFieldProps = RailsDateTimeLocalField & InputProps
+
+export const DateTimeLocalField = ({
+ label,
+ errorKey,
+ ...rest
+}: DateTimeLocalFieldProps) => {
+ const heroProps = dateTimeLocalFieldToHeroProps(rest)
+ const errorMessage = useErrorMessage(errorKey)
+
+ return (
+
+ {label}
+
+
+ {(segment) => }
+
+
+ {errorMessage && {errorMessage}}
+
+ )
+}
+
+export type TimeFieldProps = RailsTimeField & InputProps
+
+export const TimeField = ({ label, errorKey, ...rest }: TimeFieldProps) => {
+ const heroProps = timeFieldToHeroProps(rest)
+ const errorMessage = useErrorMessage(errorKey)
+
+ return (
+
+ {label}
+
+
+ {(segment) => }
+
+
+ {errorMessage && {errorMessage}}
+
+ )
+}
+
+export type SearchFieldProps = RailsSearchField & InputProps
+
+export const SearchField = ({
+ label,
+ errorKey,
+ autosave: _autosave,
+ results: _results,
+ onsearch: _onsearch,
+ incremental: _incremental,
+ ...rest
+}: SearchFieldProps) => {
+ const { fieldProps, inputProps } = textFieldToHeroProps(rest)
+ const errorMessage = useErrorMessage(errorKey)
+
+ return (
+
+ {label}
+
+ {errorMessage && {errorMessage}}
+
+ )
+}
+
+export type TelFieldProps = RailsTelField & InputProps
+
+export const TelField = ({ label, errorKey, ...rest }: TelFieldProps) => {
+ const { fieldProps, inputProps } = textFieldToHeroProps(rest)
+ const errorMessage = useErrorMessage(errorKey)
+
+ return (
+
+ {label}
+
+ {errorMessage && {errorMessage}}
+
+ )
+}
+
+export type UrlFieldProps = RailsUrlField & InputProps
+
+export const UrlField = ({ label, errorKey, ...rest }: UrlFieldProps) => {
+ const { fieldProps, inputProps } = textFieldToHeroProps(rest)
+ const errorMessage = useErrorMessage(errorKey)
+
+ return (
+
+ {label}
+
+ {errorMessage && {errorMessage}}
+
+ )
+}
+
+export type MonthFieldProps = RailsMonthField & InputProps
+
+export const MonthField = ({
+ label,
+ errorKey,
+ type: _type,
+ ...rest
+}: MonthFieldProps) => {
+ const errorMessage = useErrorMessage(errorKey)
+
+ return (
+
+ {label}
+
+ {errorMessage && {errorMessage}}
+
+ )
+}
+
+export type NumberFieldProps = RailsNumberField & InputProps
+
+export const NumberField = ({ label, errorKey, ...rest }: NumberFieldProps) => {
+ const heroProps = numberFieldToHeroProps(rest)
+ const errorMessage = useErrorMessage(errorKey)
+
+ return (
+
+ {label}
+
+
+
+
+
+ {errorMessage && {errorMessage}}
+
+ )
+}
+
+export type RangeFieldProps = RailsRangeField & InputProps
+
+export const RangeField = ({
+ label,
+ errorKey,
+ value,
+ defaultValue,
+ type: _type,
+ min,
+ max,
+ ...rest
+}: RangeFieldProps) => {
+ const errorMessage = useErrorMessage(errorKey)
+ const numericValue = value ? Number(value) : undefined
+ const numericDefault = defaultValue ? Number(defaultValue) : undefined
+
+ return (
+
+ {label}
+
+
+ {({ state }) => (
+ <>
+
+ {state.values.map((_: number, i: number) => (
+
+ ))}
+ >
+ )}
+
+ {errorMessage}
+
+ )
+}
+
+export type PasswordFieldProps = RailsPasswordField & InputProps
+
+export const PasswordField = ({
+ label,
+ errorKey,
+ ...rest
+}: PasswordFieldProps) => {
+ const { fieldProps, inputProps } = textFieldToHeroProps(rest)
+ const errorMessage = useErrorMessage(errorKey)
+
+ return (
+
+ {label}
+
+ {errorMessage && {errorMessage}}
+
+ )
+}
+
+type SelectProps = (RailsSingleSelect | RailsMultiSelect) & InputProps
+
+export const Select = ({
+ includeHidden,
+ label,
+ errorKey,
+ multiple,
+ type: _type,
+ options,
+ ...rest
+}: SelectProps) => {
+ const errorMessage = useErrorMessage(errorKey)
+ const { name, id } = rest
+ const addHidden = includeHidden && multiple
+
+ const selectedValue = 'value' in rest ? rest.value : undefined
+ const selectedDefault = 'defaultValue' in rest ? rest.defaultValue : undefined
+
+ const selectionProps: Record = {}
+ if (multiple) {
+ selectionProps.selectionMode = 'multiple'
+ if (selectedValue) {
+ selectionProps.value = Array.isArray(selectedValue)
+ ? selectedValue
+ : [selectedValue]
+ } else if (selectedDefault) {
+ selectionProps.defaultValue = Array.isArray(selectedDefault)
+ ? selectedDefault
+ : [selectedDefault]
+ }
+ } else {
+ if (selectedValue) {
+ selectionProps.value = Array.isArray(selectedValue)
+ ? selectedValue[0]
+ : selectedValue
+ } else if (selectedDefault) {
+ selectionProps.defaultValue = Array.isArray(selectedDefault)
+ ? selectedDefault[0]
+ : selectedDefault
+ }
+ }
+
+ const hasGroups = options.some((item) => 'options' in item)
+
+ return (
+ <>
+ {addHidden && (
+
+ )}
+
+ {label}
+
+
+
+
+
+
+ {hasGroups
+ ? options.map((item) => {
+ if ('options' in item) {
+ return (
+
+ {item.label}
+ {item.options.map((opt) => (
+
+ {opt.label}
+
+
+ ))}
+
+ )
+ }
+ return (
+
+ {item.label}
+
+
+ )
+ })
+ : options.map((item) => {
+ if ('options' in item) return null
+ return (
+
+ {item.label}
+
+
+ )
+ })}
+
+
+ {errorMessage && {errorMessage}}
+
+ >
+ )
+}
+
+export type TextAreaProps = RailsTextArea & InputProps
+
+export const TextArea = ({
+ label,
+ errorKey,
+ type: _type,
+ ...rest
+}: TextAreaProps) => {
+ const errorMessage = useErrorMessage(errorKey)
+
+ return (
+
+ {label}
+
+ {errorMessage && {errorMessage}}
+
+ )
+}
+
+export type FileFieldProps = RailsFileField & InputProps
+
+export const FileField = ({
+ type: _type,
+ label,
+ errorKey,
+ ...rest
+}: FileFieldProps) => {
+ const errorMessage = useErrorMessage(errorKey)
+
+ return (
+
+ {label}
+
+ {errorMessage && {errorMessage}}
+
+ )
+}
+
+export type SubmitButtonProps = RailsSubmitButton
+
+export const SubmitButton = ({
+ type: _type,
+ text,
+ ...rest
+}: SubmitButtonProps) => {
+ return (
+
+ {text}
+
+ )
+}
diff --git a/wrappers/ts/heroui/v3/package.json b/wrappers/ts/heroui/v3/package.json
new file mode 100644
index 0000000..198c7f1
--- /dev/null
+++ b/wrappers/ts/heroui/v3/package.json
@@ -0,0 +1,12 @@
+{
+ "name": "@candy_wrapper/heroui-v3",
+ "private": true,
+ "scripts": {
+ "typecheck": "tsc --noEmit",
+ "build": "tsc --project tsconfig.build.json && mkdir -p ../../../../wrappers/js/heroui/v3 && cp dist/wrappers/ts/heroui/v3/index.jsx ../../../../wrappers/js/heroui/v3/index.jsx && rm -rf dist"
+ },
+ "devDependencies": {
+ "@heroui/react": "3.2.4",
+ "@internationalized/date": "^3.0.0"
+ }
+}
diff --git a/wrappers/ts/heroui/v3/tsconfig.build.json b/wrappers/ts/heroui/v3/tsconfig.build.json
new file mode 100644
index 0000000..34f2413
--- /dev/null
+++ b/wrappers/ts/heroui/v3/tsconfig.build.json
@@ -0,0 +1,14 @@
+{
+ "extends": "../../../../tsconfig",
+ "compilerOptions": {
+ "removeComments": false,
+ "declaration": false,
+ "moduleResolution": "bundler",
+ "skipLibCheck": true,
+ "outDir": "./dist",
+ "paths": {
+ "@thoughtbot/candy_wrapper": ["../../../../src/index"]
+ }
+ },
+ "include": ["./index.tsx"]
+}
diff --git a/wrappers/ts/heroui/v3/tsconfig.json b/wrappers/ts/heroui/v3/tsconfig.json
new file mode 100644
index 0000000..3ddf145
--- /dev/null
+++ b/wrappers/ts/heroui/v3/tsconfig.json
@@ -0,0 +1,10 @@
+{
+ "extends": "../../../../tsconfig",
+ "compilerOptions": {
+ "noEmit": true,
+ "paths": {
+ "@thoughtbot/candy_wrapper": ["../../../../src/index"]
+ }
+ },
+ "include": ["./**/*.ts", "./**/*.tsx"]
+}
diff --git a/wrappers/ts/mui/v9/Checkbox.test.jsx b/wrappers/ts/mui/v9/Checkbox.test.jsx
new file mode 100644
index 0000000..3835c47
--- /dev/null
+++ b/wrappers/ts/mui/v9/Checkbox.test.jsx
@@ -0,0 +1,112 @@
+import React from 'react'
+import { render } from '@testing-library/react'
+import { Checkbox, ValidationContext } from '.'
+
+const buildPayload = () => {
+ return {
+ type: 'checkbox',
+ value: '1',
+ uncheckedValue: '0',
+ defaultChecked: true,
+ name: 'post[admin]',
+ id: 'post_admin',
+ }
+}
+
+describe('Checkbox', () => {
+ it('renders', () => {
+ const payload = buildPayload()
+
+ const { getByLabelText } = render(
+
+ )
+
+ const element = getByLabelText('Is admin')
+ expect(element).not.toBe(null)
+
+ expect(element.required).toBeFalsy()
+ expect(element.value).toEqual('1')
+ expect(element.type).toEqual('checkbox')
+ })
+
+ it('renders defaultSelected', () => {
+ const payload = buildPayload()
+ payload.defaultChecked = true
+
+ const { getByLabelText } = render(
+
+ )
+
+ const input = getByLabelText('Is admin')
+ expect(input.checked).toEqual(true)
+ })
+
+ it('renders defaultSelected false', () => {
+ const payload = buildPayload()
+ payload.defaultChecked = false
+
+ const { getByLabelText } = render(
+
+ )
+
+ const input = getByLabelText('Is admin')
+ expect(input.checked).toEqual(false)
+ })
+
+ it('renders selected', () => {
+ const payload = buildPayload()
+ delete payload.defaultChecked
+ payload.checked = true
+
+ const { getByLabelText } = render(
+
+ )
+
+ const input = getByLabelText('Is admin')
+ expect(input.checked).toEqual(true)
+ })
+
+ it('renders unselected', () => {
+ const payload = buildPayload()
+ delete payload.defaultChecked
+ payload.checked = false
+
+ const { getByLabelText } = render(
+
+ )
+ const input = getByLabelText('Is admin')
+ expect(input.checked).toEqual(false)
+ })
+
+ it('adds a hidden input if includeHidden is true', () => {
+ const payload = buildPayload()
+ payload.includeHidden = true
+ payload.uncheckedValue = '10'
+
+ const { container } = render(
+
+ )
+
+ const hiddenInput = container.querySelector('input[type=hidden]')
+ expect(hiddenInput).not.toBe(null)
+ expect(hiddenInput.name).toEqual('post[admin]')
+ expect(hiddenInput.value).toEqual('10')
+ })
+
+ it('renders with errors', async () => {
+ const payload = buildPayload()
+
+ const validationErrors = {
+ is_admin: 'Admin invalid',
+ }
+
+ const { getByText } = render(
+
+
+
+ )
+
+ const element = getByText('Admin invalid')
+ expect(element).not.toBe(null)
+ })
+})
diff --git a/wrappers/ts/mui/v9/CollectionCheckboxes.test.jsx b/wrappers/ts/mui/v9/CollectionCheckboxes.test.jsx
new file mode 100644
index 0000000..e017c64
--- /dev/null
+++ b/wrappers/ts/mui/v9/CollectionCheckboxes.test.jsx
@@ -0,0 +1,105 @@
+import React from 'react'
+import { render } from '@testing-library/react'
+import { CollectionCheckboxes, ValidationContext } from '.'
+
+const buildCheckboxPayload = (value, label) => {
+ return {
+ type: 'checkbox',
+ value,
+ defaultChecked: true,
+ label,
+ name: 'post[author_ids][]',
+ id: `post_author_ids_${value}`,
+ }
+}
+
+const buildPayload = () => {
+ return {
+ collection: [
+ buildCheckboxPayload(1, 'one'),
+ buildCheckboxPayload(2, 'two'),
+ ],
+ includeHidden: true,
+ label: 'authors',
+ required: false,
+ }
+}
+
+describe('CollectionCheckboxes', () => {
+ it('renders', () => {
+ const payload = buildPayload()
+
+ const { getByLabelText } = render(
+
+ )
+
+ let input = getByLabelText('one')
+
+ expect(input.required).toBeFalsy()
+ expect(input.value).toEqual('1')
+ expect(input.type).toEqual('checkbox')
+
+ input = getByLabelText('two')
+
+ expect(input.required).toBeFalsy()
+ expect(input.value).toEqual('2')
+ expect(input.type).toEqual('checkbox')
+ })
+
+ it('renders nothing when the collection is blank', () => {
+ const payload = buildPayload()
+ payload.collection = []
+
+ const { container } = render(
+
+ )
+
+ expect(container.innerHTML).toEqual('')
+ })
+
+ it('renders hidden ', () => {
+ const payload = buildPayload()
+ payload.includeHidden = true
+
+ const { container } = render(
+
+ )
+
+ const hiddenInput = container.querySelector('input[type=hidden]')
+ expect(hiddenInput.name).toEqual('post[author_ids][]')
+ expect(hiddenInput.value).toEqual('')
+ })
+
+ it('renders with error highlighting', async () => {
+ const payload = buildPayload()
+
+ const validationErrors = {
+ author_ids: 'id invalid',
+ }
+
+ const { getByText } = render(
+
+
+
+ )
+
+ const errorField = getByText('id invalid')
+ expect(errorField).not.toBeNull()
+ })
+})
diff --git a/wrappers/ts/mui/v9/CollectionRadioButtons.test.jsx b/wrappers/ts/mui/v9/CollectionRadioButtons.test.jsx
new file mode 100644
index 0000000..a6c5408
--- /dev/null
+++ b/wrappers/ts/mui/v9/CollectionRadioButtons.test.jsx
@@ -0,0 +1,105 @@
+import React from 'react'
+import { render } from '@testing-library/react'
+import { CollectionRadioButtons, ValidationContext } from '.'
+
+const buildRadioButtonPayload = (value, label, rest) => {
+ return {
+ type: 'radio',
+ value,
+ label,
+ name: 'post[subscribe]',
+ id: `post_subscribe_${value}`,
+ ...rest,
+ }
+}
+
+const buildPayload = () => {
+ return {
+ collection: [
+ buildRadioButtonPayload(1, 'one', { defaultChecked: true }),
+ buildRadioButtonPayload(2, 'two'),
+ ],
+ includeHidden: true,
+ label: 'Subscribe',
+ required: false,
+ }
+}
+
+describe('CollectionRadioButtons', () => {
+ it('renders', () => {
+ const payload = buildPayload()
+
+ const { getByLabelText } = render(
+
+ )
+
+ let input = getByLabelText('one')
+
+ expect(input.required).toBeFalsy()
+ expect(input.value).toEqual('1')
+ expect(input.type).toEqual('radio')
+
+ input = getByLabelText('two')
+
+ expect(input.required).toBeFalsy()
+ expect(input.value).toEqual('2')
+ expect(input.type).toEqual('radio')
+ })
+
+ it('renders nothing when the collection is blank', () => {
+ const payload = buildPayload()
+ payload.collection = []
+
+ const { container } = render(
+
+ )
+
+ expect(container.innerHTML).toEqual('')
+ })
+
+ it('renders hidden ', () => {
+ const payload = buildPayload()
+ payload.includeHidden = true
+
+ const { container } = render(
+
+ )
+
+ const hiddenInput = container.querySelector('input[type=hidden]')
+ expect(hiddenInput.name).toEqual('post[subscribe]')
+ expect(hiddenInput.value).toEqual('')
+ })
+
+ it('renders with error highlighting', async () => {
+ const payload = buildPayload()
+
+ const validationErrors = {
+ subscribe: 'id invalid',
+ }
+
+ const { getByText } = render(
+
+
+
+ )
+
+ const errorField = getByText('id invalid')
+ expect(errorField).not.toBeNull()
+ })
+})
diff --git a/wrappers/ts/mui/v9/ColorField.test.jsx b/wrappers/ts/mui/v9/ColorField.test.jsx
new file mode 100644
index 0000000..fa104e3
--- /dev/null
+++ b/wrappers/ts/mui/v9/ColorField.test.jsx
@@ -0,0 +1,45 @@
+import React from 'react'
+import { render } from '@testing-library/react'
+import { ColorField, ValidationContext } from '.'
+
+const buildPayload = () => {
+ return {
+ type: 'color',
+ name: 'post[color]',
+ id: 'post_color',
+ required: false,
+ defaultValue: '#000000',
+ }
+}
+
+describe('ColorField', () => {
+ it('renders', () => {
+ const payload = buildPayload()
+
+ const { getByLabelText } = render(
+
+ )
+
+ const input = getByLabelText('color')
+ expect(input.required).toBeFalsy()
+ expect(input.value).toEqual('#000000')
+ expect(input.type).toEqual('color')
+ })
+
+ it('renders with field errors', async () => {
+ const payload = buildPayload()
+
+ const validationErrors = {
+ color: 'color invalid',
+ }
+
+ const { getByText } = render(
+
+
+
+ )
+
+ const errorField = getByText('color invalid')
+ expect(errorField).not.toBeNull()
+ })
+})
diff --git a/wrappers/ts/mui/v9/DateField.test.jsx b/wrappers/ts/mui/v9/DateField.test.jsx
new file mode 100644
index 0000000..f61af61
--- /dev/null
+++ b/wrappers/ts/mui/v9/DateField.test.jsx
@@ -0,0 +1,53 @@
+import React from 'react'
+import { render } from '@testing-library/react'
+import { DateField, ValidationContext } from '.'
+
+const buildPayload = () => {
+ return {
+ type: 'date',
+ name: 'post[published_on]',
+ id: 'post_published_on',
+ required: false,
+ defaultValue: '2023-01-15',
+ }
+}
+
+describe('DateField', () => {
+ it('renders', () => {
+ const payload = buildPayload()
+
+ const { getByLabelText } = render(
+
+ )
+
+ const input = getByLabelText('Published on')
+ expect(input.required).toBeFalsy()
+ expect(input.value).toEqual('2023-01-15')
+ expect(input.type).toEqual('date')
+ })
+
+ it('renders with field errors', async () => {
+ const payload = buildPayload()
+
+ const validationErrors = {
+ published_on: 'date invalid',
+ }
+
+ const { getByText } = render(
+
+
+
+ )
+
+ const errorField = getByText('date invalid')
+ expect(errorField).not.toBeNull()
+ })
+})
diff --git a/wrappers/ts/mui/v9/DateTimeLocalField.test.jsx b/wrappers/ts/mui/v9/DateTimeLocalField.test.jsx
new file mode 100644
index 0000000..a313cab
--- /dev/null
+++ b/wrappers/ts/mui/v9/DateTimeLocalField.test.jsx
@@ -0,0 +1,53 @@
+import React from 'react'
+import { render } from '@testing-library/react'
+import { DateTimeLocalField, ValidationContext } from '.'
+
+const buildPayload = () => {
+ return {
+ type: 'datetime-local',
+ name: 'post[published_at]',
+ id: 'post_published_at',
+ required: false,
+ defaultValue: '2023-01-15T10:30',
+ }
+}
+
+describe('DateTimeLocalField', () => {
+ it('renders', () => {
+ const payload = buildPayload()
+
+ const { getByLabelText } = render(
+
+ )
+
+ const input = getByLabelText('Published at')
+ expect(input.required).toBeFalsy()
+ expect(input.value).toEqual('2023-01-15T10:30')
+ expect(input.type).toEqual('datetime-local')
+ })
+
+ it('renders with field errors', async () => {
+ const payload = buildPayload()
+
+ const validationErrors = {
+ published_at: 'datetime invalid',
+ }
+
+ const { getByText } = render(
+
+
+
+ )
+
+ const errorField = getByText('datetime invalid')
+ expect(errorField).not.toBeNull()
+ })
+})
diff --git a/wrappers/ts/mui/v9/EmailField.test.jsx b/wrappers/ts/mui/v9/EmailField.test.jsx
new file mode 100644
index 0000000..05fe45e
--- /dev/null
+++ b/wrappers/ts/mui/v9/EmailField.test.jsx
@@ -0,0 +1,45 @@
+import React from 'react'
+import { render } from '@testing-library/react'
+import { EmailField, ValidationContext } from '.'
+
+const buildPayload = () => {
+ return {
+ type: 'email',
+ name: 'post[email]',
+ id: 'post_email',
+ required: false,
+ defaultValue: 'john@smith.com',
+ }
+}
+
+describe('EmailField', () => {
+ it('renders', () => {
+ const payload = buildPayload()
+
+ const { getByLabelText } = render(
+
+ )
+
+ const input = getByLabelText('Email')
+ expect(input.required).toBeFalsy()
+ expect(input.value).toEqual('john@smith.com')
+ expect(input.type).toEqual('email')
+ })
+
+ it('renders with field errors', async () => {
+ const payload = buildPayload()
+
+ const validationErrors = {
+ email: 'email invalid',
+ }
+
+ const { getByText } = render(
+
+
+
+ )
+
+ const errorField = getByText('email invalid')
+ expect(errorField).not.toBeNull()
+ })
+})
diff --git a/wrappers/ts/mui/v9/FileField.test.jsx b/wrappers/ts/mui/v9/FileField.test.jsx
new file mode 100644
index 0000000..976dcbf
--- /dev/null
+++ b/wrappers/ts/mui/v9/FileField.test.jsx
@@ -0,0 +1,43 @@
+import React from 'react'
+import { render } from '@testing-library/react'
+import { FileField, ValidationContext } from '.'
+
+const buildPayload = () => {
+ return {
+ type: 'file',
+ name: 'post[attachment]',
+ id: 'post_attachment',
+ required: false,
+ }
+}
+
+describe('FileField', () => {
+ it('renders', () => {
+ const payload = buildPayload()
+
+ const { getByLabelText } = render(
+
+ )
+
+ const input = getByLabelText('attachment')
+ expect(input.required).toBeFalsy()
+ expect(input.type).toEqual('file')
+ })
+
+ it('renders with field errors', async () => {
+ const payload = buildPayload()
+
+ const validationErrors = {
+ attachment: 'attachment invalid',
+ }
+
+ const { getByText } = render(
+
+
+
+ )
+
+ const errorField = getByText('attachment invalid')
+ expect(errorField).not.toBeNull()
+ })
+})
diff --git a/wrappers/ts/mui/v9/MonthField.test.jsx b/wrappers/ts/mui/v9/MonthField.test.jsx
new file mode 100644
index 0000000..4a5e4d7
--- /dev/null
+++ b/wrappers/ts/mui/v9/MonthField.test.jsx
@@ -0,0 +1,49 @@
+import React from 'react'
+import { render } from '@testing-library/react'
+import { MonthField, ValidationContext } from '.'
+
+const buildPayload = () => {
+ return {
+ type: 'month',
+ name: 'post[birth_month]',
+ id: 'post_birth_month',
+ required: false,
+ defaultValue: '2023-01',
+ }
+}
+
+describe('MonthField', () => {
+ it('renders', () => {
+ const payload = buildPayload()
+
+ const { getByLabelText } = render(
+
+ )
+
+ const input = getByLabelText('Birth month')
+ expect(input.required).toBeFalsy()
+ expect(input.value).toEqual('2023-01')
+ expect(input.type).toEqual('month')
+ })
+
+ it('renders with field errors', async () => {
+ const payload = buildPayload()
+
+ const validationErrors = {
+ birth_month: 'month invalid',
+ }
+
+ const { getByText } = render(
+
+
+
+ )
+
+ const errorField = getByText('month invalid')
+ expect(errorField).not.toBeNull()
+ })
+})
diff --git a/wrappers/ts/mui/v9/NumberField.test.jsx b/wrappers/ts/mui/v9/NumberField.test.jsx
new file mode 100644
index 0000000..6fa6515
--- /dev/null
+++ b/wrappers/ts/mui/v9/NumberField.test.jsx
@@ -0,0 +1,49 @@
+import React from 'react'
+import { render } from '@testing-library/react'
+import { NumberField, ValidationContext } from '.'
+
+const buildPayload = () => {
+ return {
+ type: 'number',
+ defaultValue: '2',
+ name: 'post[favs]',
+ min: 1,
+ max: 9,
+ id: 'post_favs',
+ step: 2,
+ }
+}
+
+describe('NumberField', () => {
+ it('renders', () => {
+ const payload = buildPayload()
+
+ const { getByLabelText } = render(
+
+ )
+
+ const input = getByLabelText('Favs')
+ expect(input.value).toEqual('2')
+ expect(input.type).toEqual('number')
+ expect(input.min).toEqual('1')
+ expect(input.max).toEqual('9')
+ expect(input.step).toEqual('2')
+ })
+
+ it('renders with field errors', async () => {
+ const payload = buildPayload()
+
+ const validationErrors = {
+ favs: 'favs invalid',
+ }
+
+ const { getByText } = render(
+
+
+
+ )
+
+ const errorField = getByText('favs invalid')
+ expect(errorField).not.toBeNull()
+ })
+})
diff --git a/wrappers/ts/mui/v9/PasswordField.test.jsx b/wrappers/ts/mui/v9/PasswordField.test.jsx
new file mode 100644
index 0000000..9b02ddd
--- /dev/null
+++ b/wrappers/ts/mui/v9/PasswordField.test.jsx
@@ -0,0 +1,45 @@
+import React from 'react'
+import { render } from '@testing-library/react'
+import { PasswordField, ValidationContext } from '.'
+
+const buildPayload = () => {
+ return {
+ type: 'password',
+ name: 'post[password]',
+ id: 'post_password',
+ required: false,
+ defaultValue: 'secret',
+ }
+}
+
+describe('PasswordField', () => {
+ it('renders', () => {
+ const payload = buildPayload()
+
+ const { getByLabelText } = render(
+
+ )
+
+ const input = getByLabelText('Password')
+ expect(input.required).toBeFalsy()
+ expect(input.value).toEqual('secret')
+ expect(input.type).toEqual('password')
+ })
+
+ it('renders with field errors', async () => {
+ const payload = buildPayload()
+
+ const validationErrors = {
+ password: 'password invalid',
+ }
+
+ const { getByText } = render(
+
+
+
+ )
+
+ const errorField = getByText('password invalid')
+ expect(errorField).not.toBeNull()
+ })
+})
diff --git a/wrappers/ts/mui/v9/RangeField.test.jsx b/wrappers/ts/mui/v9/RangeField.test.jsx
new file mode 100644
index 0000000..64d0ba3
--- /dev/null
+++ b/wrappers/ts/mui/v9/RangeField.test.jsx
@@ -0,0 +1,51 @@
+import React from 'react'
+import { render } from '@testing-library/react'
+import { RangeField, ValidationContext } from '.'
+
+const buildPayload = () => {
+ return {
+ type: 'range',
+ defaultValue: '2',
+ name: 'post[volume]',
+ min: 1,
+ max: 9,
+ id: 'post_volume',
+ step: 2,
+ }
+}
+
+describe('RangeField', () => {
+ it('renders', () => {
+ const payload = buildPayload()
+
+ const { getByRole, container } = render(
+
+ )
+
+ const slider = getByRole('slider')
+ expect(slider).not.toBeNull()
+ expect(slider.getAttribute('aria-valuenow')).toEqual('2')
+
+ const hiddenInput = container.querySelector('input[type=hidden]')
+ expect(hiddenInput).not.toBeNull()
+ expect(hiddenInput.name).toEqual('post[volume]')
+ expect(hiddenInput.value).toEqual('2')
+ })
+
+ it('renders with field errors', async () => {
+ const payload = buildPayload()
+
+ const validationErrors = {
+ volume: 'volume invalid',
+ }
+
+ const { getByText } = render(
+
+
+
+ )
+
+ const errorField = getByText('volume invalid')
+ expect(errorField).not.toBeNull()
+ })
+})
diff --git a/wrappers/ts/mui/v9/SearchField.test.jsx b/wrappers/ts/mui/v9/SearchField.test.jsx
new file mode 100644
index 0000000..406fe69
--- /dev/null
+++ b/wrappers/ts/mui/v9/SearchField.test.jsx
@@ -0,0 +1,45 @@
+import React from 'react'
+import { render } from '@testing-library/react'
+import { SearchField, ValidationContext } from '.'
+
+const buildPayload = () => {
+ return {
+ type: 'search',
+ name: 'post[query]',
+ id: 'post_query',
+ required: false,
+ defaultValue: 'rails',
+ }
+}
+
+describe('SearchField', () => {
+ it('renders', () => {
+ const payload = buildPayload()
+
+ const { getByLabelText } = render(
+
+ )
+
+ const input = getByLabelText('Query')
+ expect(input.required).toBeFalsy()
+ expect(input.value).toEqual('rails')
+ expect(input.type).toEqual('search')
+ })
+
+ it('renders with field errors', async () => {
+ const payload = buildPayload()
+
+ const validationErrors = {
+ query: 'query invalid',
+ }
+
+ const { getByText } = render(
+
+
+
+ )
+
+ const errorField = getByText('query invalid')
+ expect(errorField).not.toBeNull()
+ })
+})
diff --git a/wrappers/ts/mui/v9/Select.test.jsx b/wrappers/ts/mui/v9/Select.test.jsx
new file mode 100644
index 0000000..234a1c1
--- /dev/null
+++ b/wrappers/ts/mui/v9/Select.test.jsx
@@ -0,0 +1,110 @@
+import React from 'react'
+import { render } from '@testing-library/react'
+import { Select, ValidationContext } from '.'
+
+const buildPayload = () => {
+ return {
+ type: 'select',
+ name: 'post[category]',
+ id: 'post_category',
+ defaultValue: '',
+ options: [
+ { value: 'abe', label: 'abe' },
+ { value: '', label: '' },
+ { value: 'hest', label: 'hest' },
+ ],
+ }
+}
+
+describe('Select', () => {
+ describe('rendering', () => {
+ it('adds a hidden input on multiple selects if includeHidden is true', () => {
+ const payload = buildPayload()
+ payload.multiple = true
+ payload.defaultValue = [payload.defaultValue]
+ payload.includeHidden = true
+
+ const { container } = render()
+
+ const hiddenInput = container.querySelector('input[type=hidden]')
+ expect(hiddenInput).not.toBe(null)
+ expect(hiddenInput.name).toEqual('post[category]')
+ })
+
+ it('excludes a hidden input on multiple selects if includeHidden is false', () => {
+ const payload = buildPayload()
+ payload.multiple = false
+ payload.includeHidden = false
+
+ const { container } = render()
+
+ const hiddenInput = container.querySelector('input[type=hidden]')
+ expect(hiddenInput).toBe(null)
+ })
+
+ it('renders the component with options', async () => {
+ const payload = {
+ type: 'select',
+ name: 'post[category]',
+ id: 'post_category',
+ required: true,
+ defaultValue: '',
+ includeHidden: false,
+ options: [
+ { value: '', label: 'Choose a category' },
+ { value: 'abe', label: 'abe' },
+ { value: '', label: '' },
+ { value: 'hest', label: 'hest' },
+ ],
+ }
+
+ const { getByRole } = render()
+
+ const combobox = getByRole('combobox')
+ expect(combobox).not.toBeNull()
+ })
+
+ it('renders with nested options (optgroup flattened to MenuItems)', async () => {
+ const payload = {
+ type: 'select',
+ name: 'post[category]',
+ id: 'post_category',
+ includeHidden: false,
+ defaultValue: 'abe',
+ options: [
+ { value: 'abe', label: 'abe' },
+ {
+ label: 'sports',
+ options: [
+ { value: 'soccer', label: 'Soccer' },
+ { value: 'baseball', label: 'Baseball' },
+ ],
+ },
+ { value: 'hest', label: 'hest' },
+ ],
+ }
+
+ const { getByRole } = render()
+
+ const combobox = getByRole('combobox')
+ expect(combobox).not.toBeNull()
+ })
+
+ it('renders with errors', async () => {
+ const payload = buildPayload()
+
+ const validationErrors = {
+ category: 'category invalid',
+ }
+
+ const { getByText } = render(
+
+
+
+ )
+
+ const errorField = getByText('category invalid')
+ expect(errorField).not.toBeNull()
+ })
+ })
+})
diff --git a/wrappers/ts/mui/v9/TelField.test.jsx b/wrappers/ts/mui/v9/TelField.test.jsx
new file mode 100644
index 0000000..bd7d92f
--- /dev/null
+++ b/wrappers/ts/mui/v9/TelField.test.jsx
@@ -0,0 +1,45 @@
+import React from 'react'
+import { render } from '@testing-library/react'
+import { TelField, ValidationContext } from '.'
+
+const buildPayload = () => {
+ return {
+ type: 'tel',
+ name: 'post[phone]',
+ id: 'post_phone',
+ required: false,
+ defaultValue: '555-1234',
+ }
+}
+
+describe('TelField', () => {
+ it('renders', () => {
+ const payload = buildPayload()
+
+ const { getByLabelText } = render(
+
+ )
+
+ const input = getByLabelText('Phone')
+ expect(input.required).toBeFalsy()
+ expect(input.value).toEqual('555-1234')
+ expect(input.type).toEqual('tel')
+ })
+
+ it('renders with field errors', async () => {
+ const payload = buildPayload()
+
+ const validationErrors = {
+ phone: 'phone invalid',
+ }
+
+ const { getByText } = render(
+
+
+
+ )
+
+ const errorField = getByText('phone invalid')
+ expect(errorField).not.toBeNull()
+ })
+})
diff --git a/wrappers/ts/mui/v9/TextArea.test.jsx b/wrappers/ts/mui/v9/TextArea.test.jsx
new file mode 100644
index 0000000..a9b686c
--- /dev/null
+++ b/wrappers/ts/mui/v9/TextArea.test.jsx
@@ -0,0 +1,48 @@
+import React from 'react'
+import { render } from '@testing-library/react'
+import { TextArea, ValidationContext } from '.'
+
+const buildPayload = () => {
+ return {
+ type: 'text',
+ name: 'post[category]',
+ id: 'post_category',
+ required: false,
+ defaultValue: 'books',
+ }
+}
+
+describe('TextArea', () => {
+ it('renders', () => {
+ const payload = buildPayload()
+
+ const { getByLabelText } = render(
+
+ )
+
+ const input = getByLabelText('category')
+ expect(input.required).toBeFalsy()
+ expect(input.value).toEqual('books')
+ expect(input.tagName.toLowerCase()).toEqual('textarea')
+ })
+
+ it('renders with field errors', async () => {
+ const payload = buildPayload()
+
+ const validationErrors = {
+ category: 'title invalid',
+ }
+
+ const { getByText, getByLabelText } = render(
+
+
+
+ )
+
+ const errorField = getByText('title invalid')
+ expect(errorField).not.toBeNull()
+
+ const input = getByLabelText('category')
+ expect(input.value).toEqual('books')
+ })
+})
diff --git a/wrappers/ts/mui/v9/TextField.test.jsx b/wrappers/ts/mui/v9/TextField.test.jsx
new file mode 100644
index 0000000..77214c9
--- /dev/null
+++ b/wrappers/ts/mui/v9/TextField.test.jsx
@@ -0,0 +1,48 @@
+import React from 'react'
+import { render } from '@testing-library/react'
+import { TextField, ValidationContext } from '.'
+
+const buildPayload = () => {
+ return {
+ type: 'text',
+ name: 'post[category]',
+ id: 'post_category',
+ required: false,
+ defaultValue: 'books',
+ }
+}
+
+describe('TextField', () => {
+ it('renders', () => {
+ const payload = buildPayload()
+
+ const { getByLabelText } = render(
+
+ )
+
+ const input = getByLabelText('category')
+ expect(input.required).toBeFalsy()
+ expect(input.value).toEqual('books')
+ expect(input.type).toEqual('text')
+ })
+
+ it('renders with field errors', async () => {
+ const payload = buildPayload()
+
+ const validationErrors = {
+ category: 'title invalid',
+ }
+
+ const { getByText, getByLabelText } = render(
+
+
+
+ )
+
+ const errorField = getByText('title invalid')
+ expect(errorField).not.toBeNull()
+
+ const input = getByLabelText('category')
+ expect(input.value).toEqual('books')
+ })
+})
diff --git a/wrappers/ts/mui/v9/TimeField.test.jsx b/wrappers/ts/mui/v9/TimeField.test.jsx
new file mode 100644
index 0000000..9c0fa0c
--- /dev/null
+++ b/wrappers/ts/mui/v9/TimeField.test.jsx
@@ -0,0 +1,45 @@
+import React from 'react'
+import { render } from '@testing-library/react'
+import { TimeField, ValidationContext } from '.'
+
+const buildPayload = () => {
+ return {
+ type: 'time',
+ name: 'post[start_time]',
+ id: 'post_start_time',
+ required: false,
+ defaultValue: '10:30',
+ }
+}
+
+describe('TimeField', () => {
+ it('renders', () => {
+ const payload = buildPayload()
+
+ const { getByLabelText } = render(
+
+ )
+
+ const input = getByLabelText('Start time')
+ expect(input.required).toBeFalsy()
+ expect(input.value).toEqual('10:30')
+ expect(input.type).toEqual('time')
+ })
+
+ it('renders with field errors', async () => {
+ const payload = buildPayload()
+
+ const validationErrors = {
+ start_time: 'time invalid',
+ }
+
+ const { getByText } = render(
+
+
+
+ )
+
+ const errorField = getByText('time invalid')
+ expect(errorField).not.toBeNull()
+ })
+})
diff --git a/wrappers/ts/mui/v9/UrlField.test.jsx b/wrappers/ts/mui/v9/UrlField.test.jsx
new file mode 100644
index 0000000..3ba5774
--- /dev/null
+++ b/wrappers/ts/mui/v9/UrlField.test.jsx
@@ -0,0 +1,45 @@
+import React from 'react'
+import { render } from '@testing-library/react'
+import { UrlField, ValidationContext } from '.'
+
+const buildPayload = () => {
+ return {
+ type: 'url',
+ name: 'post[website]',
+ id: 'post_website',
+ required: false,
+ defaultValue: 'https://example.com',
+ }
+}
+
+describe('UrlField', () => {
+ it('renders', () => {
+ const payload = buildPayload()
+
+ const { getByLabelText } = render(
+
+ )
+
+ const input = getByLabelText('Website')
+ expect(input.required).toBeFalsy()
+ expect(input.value).toEqual('https://example.com')
+ expect(input.type).toEqual('url')
+ })
+
+ it('renders with field errors', async () => {
+ const payload = buildPayload()
+
+ const validationErrors = {
+ website: 'url invalid',
+ }
+
+ const { getByText } = render(
+
+
+
+ )
+
+ const errorField = getByText('url invalid')
+ expect(errorField).not.toBeNull()
+ })
+})
diff --git a/wrappers/ts/mui/v9/index.tsx b/wrappers/ts/mui/v9/index.tsx
new file mode 100644
index 0000000..b149dd2
--- /dev/null
+++ b/wrappers/ts/mui/v9/index.tsx
@@ -0,0 +1,657 @@
+import React, {
+ createContext,
+ ReactNode,
+ useContext,
+ useMemo,
+ ComponentProps,
+} from 'react'
+
+export type {
+ CheckboxField as RailsCheckboxFieldProps,
+ CollectionCheckboxesField as RailsCollectionCheckboxesFieldProps,
+ CollectionRadioButtonsField as RailsCollectionRadioButtonsFieldProps,
+ ColorField as RailsColorFieldProps,
+ DateField as RailsDateFieldProps,
+ DateTimeLocalField as RailsDateTimeLocalFieldProps,
+ EmailField as RailsEmailFieldProps,
+ FileField as RailsFileFieldProps,
+ HiddenField as RailsHiddenFieldProps,
+ MonthField as RailsMonthFieldProps,
+ NumberField as RailsNumberFieldProps,
+ PasswordField as RailsPasswordFieldProps,
+ RangeField as RailsRangeFieldProps,
+ SearchField as RailsSearchFieldProps,
+ Select as RailsSelectProps,
+ SubmitProps as RailsSubmitButtonProps,
+ TelField as RailsTelFieldProps,
+ TextArea as RailsTextAreaProps,
+ TextField as RailsTextFieldProps,
+ TimeField as RailsTimeFieldProps,
+ UrlField as RailsUrlFieldProps,
+ HTMLFormProps as RailsHTMLFormProps,
+ ValidationErrors,
+} from '@thoughtbot/candy_wrapper'
+
+import {
+ CheckboxField as RailsCheckboxField,
+ CollectionCheckboxesField as RailsCollectionCheckboxesField,
+ CollectionRadioButtonsField as RailsCollectionRadioButtonsField,
+ ColorField as RailsColorField,
+ DateField as RailsDateField,
+ DateTimeLocalField as RailsDateTimeLocalField,
+ EmailField as RailsEmailField,
+ FileField as RailsFileField,
+ HiddenField as RailsHiddenField,
+ MonthField as RailsMonthField,
+ NumberField as RailsNumberField,
+ PasswordField as RailsPasswordField,
+ RangeField as RailsRangeField,
+ SearchField as RailsSearchField,
+ SingleSelect as RailsSingleSelect,
+ MultiSelect as RailsMultiSelect,
+ TelField as RailsTelField,
+ TextField as RailsTextField,
+ TextArea as RailsTextArea,
+ TimeField as RailsTimeField,
+ UrlField as RailsUrlField,
+ HTMLFormProps as RailsHTMLFormProps,
+ SubmitProps as RailsSubmitButton,
+ ValidationErrors,
+} from '@thoughtbot/candy_wrapper'
+
+import TextField from '@mui/material/TextField'
+import MuiCheckbox from '@mui/material/Checkbox'
+import FormControlLabel from '@mui/material/FormControlLabel'
+import Radio from '@mui/material/Radio'
+import RadioGroup from '@mui/material/RadioGroup'
+import MuiSelect from '@mui/material/Select'
+import MenuItem from '@mui/material/MenuItem'
+import Slider from '@mui/material/Slider'
+import Button from '@mui/material/Button'
+import FormControl from '@mui/material/FormControl'
+import InputLabel from '@mui/material/InputLabel'
+import FormHelperText from '@mui/material/FormHelperText'
+import FormLabel from '@mui/material/FormLabel'
+
+export const ValidationContext = createContext({})
+
+export const useErrorMessage = (errorKey?: string) => {
+ const errors = useContext(ValidationContext)
+
+ return useMemo(() => {
+ if (!errorKey) {
+ return null
+ }
+
+ const validationError = errors[errorKey]
+ const hasErrors = errorKey && validationError
+
+ if (!hasErrors) {
+ return null
+ }
+
+ const errorMessages = Array.isArray(validationError)
+ ? validationError
+ : [validationError]
+
+ return errorMessages.join(' ')
+ }, [errors, errorKey])
+}
+
+export type ExtrasProps = Record
+
+export const Extras = (hiddenInputAttributes: ExtrasProps) => {
+ const hiddenProps = Object.values(hiddenInputAttributes)
+ const hiddenInputs = hiddenProps.map((props: RailsHiddenField) => (
+
+ ))
+
+ return <>{hiddenInputs}>
+}
+
+export interface FormProps {
+ extras: ExtrasProps
+ inputs: T
+ form: RailsHTMLFormProps
+}
+
+type FormElementProps = React.FormHTMLAttributes & {
+ extras: ExtrasProps
+ validationErrors?: ValidationErrors
+}
+
+export const Form = ({
+ extras,
+ validationErrors = {},
+ children,
+ ...props
+}: FormElementProps) => {
+ return (
+
+ )
+}
+
+type InputProps = {
+ label: string
+ errorKey?: string
+}
+
+// TextField
+type TextFieldComponentProps = ComponentProps &
+ RailsTextField &
+ InputProps
+export const TextFieldComponent = (props: TextFieldComponentProps) => {
+ // Strip candy_wrapper-specific props
+ const { type, errorKey, label, ...rest } = props
+
+ // Transform
+ const errorMessage = useErrorMessage(errorKey)
+
+ // Spread rest into MUI TextField
+ return (
+
+ )
+}
+export { TextFieldComponent as TextField }
+
+// EmailField
+type EmailFieldProps = ComponentProps &
+ RailsEmailField &
+ InputProps
+export const EmailField = (props: EmailFieldProps) => {
+ const { type, errorKey, label, ...rest } = props
+
+ const errorMessage = useErrorMessage(errorKey)
+
+ return (
+
+ )
+}
+
+// ColorField — also strips `color` since it clashes with MUI's `color` prop
+type ColorFieldProps = ComponentProps &
+ RailsColorField &
+ InputProps
+export const ColorField = (props: ColorFieldProps) => {
+ const { type, color, errorKey, label, ...rest } = props
+
+ const errorMessage = useErrorMessage(errorKey)
+
+ return (
+
+ )
+}
+
+// DateField
+type DateFieldProps = ComponentProps &
+ RailsDateField &
+ InputProps
+export const DateField = (props: DateFieldProps) => {
+ const { type, errorKey, label, ...rest } = props
+
+ const errorMessage = useErrorMessage(errorKey)
+
+ return (
+
+ )
+}
+
+// DateTimeLocalField
+type DateTimeLocalFieldProps = ComponentProps &
+ RailsDateTimeLocalField &
+ InputProps
+export const DateTimeLocalField = (props: DateTimeLocalFieldProps) => {
+ const { type, errorKey, label, ...rest } = props
+
+ const errorMessage = useErrorMessage(errorKey)
+
+ return (
+
+ )
+}
+
+// SearchField
+type SearchFieldProps = ComponentProps &
+ RailsSearchField &
+ InputProps
+export const SearchField = (props: SearchFieldProps) => {
+ const { type, errorKey, label, ...rest } = props
+
+ const errorMessage = useErrorMessage(errorKey)
+
+ return (
+
+ )
+}
+
+// TelField
+type TelFieldProps = ComponentProps &
+ RailsTelField &
+ InputProps
+export const TelField = (props: TelFieldProps) => {
+ const { type, errorKey, label, ...rest } = props
+
+ const errorMessage = useErrorMessage(errorKey)
+
+ return (
+
+ )
+}
+
+// UrlField
+type UrlFieldProps = ComponentProps &
+ RailsUrlField &
+ InputProps
+export const UrlField = (props: UrlFieldProps) => {
+ const { type, errorKey, label, ...rest } = props
+
+ const errorMessage = useErrorMessage(errorKey)
+
+ return (
+
+ )
+}
+
+// MonthField
+type MonthFieldProps = ComponentProps &
+ RailsMonthField &
+ InputProps
+export const MonthField = (props: MonthFieldProps) => {
+ const { type, errorKey, label, ...rest } = props
+
+ const errorMessage = useErrorMessage(errorKey)
+
+ return (
+
+ )
+}
+
+// TimeField
+type TimeFieldProps = ComponentProps &
+ RailsTimeField &
+ InputProps
+export const TimeField = (props: TimeFieldProps) => {
+ const { type, errorKey, label, ...rest } = props
+
+ const errorMessage = useErrorMessage(errorKey)
+
+ return (
+
+ )
+}
+
+// NumberField — strips `min`, `max`, `step` and transforms them into slotProps
+type NumberFieldProps = ComponentProps &
+ RailsNumberField &
+ InputProps
+export const NumberField = (props: NumberFieldProps) => {
+ const { type, errorKey, label, min, max, step, ...rest } = props
+
+ const errorMessage = useErrorMessage(errorKey)
+
+ return (
+
+ )
+}
+
+// PasswordField
+type PasswordFieldProps = ComponentProps &
+ RailsPasswordField &
+ InputProps
+export const PasswordField = (props: PasswordFieldProps) => {
+ const { type, errorKey, label, ...rest } = props
+
+ const errorMessage = useErrorMessage(errorKey)
+
+ return (
+
+ )
+}
+
+// RangeField — transforms string value/defaultValue to numbers for MUI Slider
+type RangeFieldProps = ComponentProps &
+ RailsRangeField &
+ InputProps
+export const RangeField = (props: RangeFieldProps) => {
+ // Strip candy_wrapper-specific props
+ const { type, errorKey, label, value, defaultValue, ...rest } = props
+
+ // Transform string values to numbers for MUI Slider
+ const errorMessage = useErrorMessage(errorKey)
+ const sliderValue = value !== undefined ? Number(value) : undefined
+ const sliderDefaultValue =
+ defaultValue !== undefined ? Number(defaultValue) : undefined
+
+ // Spread rest into Slider
+ return (
+
+ {label}
+
+
+ {errorMessage && {errorMessage}}
+
+ )
+}
+
+// Checkbox — strips includeHidden, uncheckedValue; passes defaultChecked/checked through natively
+type CheckboxProps = ComponentProps &
+ RailsCheckboxField &
+ InputProps
+export const Checkbox = (props: CheckboxProps) => {
+ // Strip candy_wrapper-specific props
+ const { type, includeHidden, uncheckedValue, errorKey, label, ...rest } = props
+
+ // Transform
+ const errorMessage = useErrorMessage(errorKey)
+ const { name } = rest
+
+ // Spread rest into MuiCheckbox (defaultChecked/checked pass through natively)
+ return (
+
+ {includeHidden && (
+
+ )}
+ } />
+ {errorMessage && {errorMessage}}
+
+ )
+}
+
+// CollectionCheckboxes — strips candy_wrapper props from each collection item
+type CollectionCheckboxesFieldProps = RailsCollectionCheckboxesField &
+ InputProps
+export const CollectionCheckboxes = (props: CollectionCheckboxesFieldProps) => {
+ // Strip candy_wrapper-specific props
+ const { includeHidden, collection, label, errorKey } = props
+
+ const errorMessage = useErrorMessage(errorKey)
+
+ if (collection.length === 0) {
+ return null
+ }
+
+ const { name } = collection[0]
+
+ const checkboxes = collection.map((item) => {
+ // Strip candy_wrapper-specific props from each collection item
+ const { label: checkboxLabel, type, includeHidden: _ih, uncheckedValue: _uv, ...rest } = item
+
+ return (
+ }
+ />
+ )
+ })
+
+ return (
+
+ {includeHidden && (
+
+ )}
+ {label}
+ {checkboxes}
+ {errorMessage && {errorMessage}}
+
+ )
+}
+
+// CollectionRadioButtons — extracts value from checked/defaultChecked items for RadioGroup
+type CollectionRadioButtonsFieldProps = RailsCollectionRadioButtonsField &
+ InputProps
+export const CollectionRadioButtons = (props: CollectionRadioButtonsFieldProps) => {
+ // Strip candy_wrapper-specific props
+ const { includeHidden, collection, label, errorKey } = props
+
+ const errorMessage = useErrorMessage(errorKey)
+
+ if (collection.length === 0) {
+ return null
+ }
+
+ // Transform: extract value/defaultValue for RadioGroup from checked/defaultChecked items
+ const defaultItem = collection.find((option) => !!option.defaultChecked)
+ const checkedItem = collection.find((option) => !!option.checked)
+
+ const valueProps: { value?: string; defaultValue?: string } = {}
+ if (defaultItem) {
+ valueProps.defaultValue = defaultItem.value
+ } else if (checkedItem) {
+ valueProps.value = checkedItem.value
+ }
+
+ const { name } = collection[0]
+
+ const radioButtons = collection.map((item) => {
+ // Strip candy_wrapper-specific props from each collection item
+ const { label: radioLabel, checked, defaultChecked, type, ...rest } = item
+
+ return (
+ }
+ />
+ )
+ })
+
+ return (
+
+ {includeHidden && (
+
+ )}
+ {label}
+
+ {radioButtons}
+
+ {errorMessage && {errorMessage}}
+
+ )
+}
+
+// Select — strips candy_wrapper props, transforms options to MenuItem children
+type SelectProps = (
+ | (ComponentProps & RailsSingleSelect)
+ | (ComponentProps & RailsMultiSelect)
+) &
+ InputProps
+export const Select = (props: SelectProps) => {
+ // Strip candy_wrapper-specific props
+ const { type, includeHidden, options, errorKey, label, name, id, multiple, ...rest } = props
+
+ // Transform
+ const errorMessage = useErrorMessage(errorKey)
+ const addHidden = includeHidden && multiple
+
+ // Transform options into MenuItem children (flattening optgroups)
+ const menuItems = options.flatMap((item) => {
+ if ('options' in item) {
+ return item.options.map((opt) => (
+
+ ))
+ } else {
+ return (
+
+ )
+ }
+ })
+
+ const labelId = id ? `${id}-label` : undefined
+
+ // Spread rest into MuiSelect
+ return (
+
+ {addHidden && (
+
+ )}
+ {label}
+
+ {menuItems}
+
+ {errorMessage && {errorMessage}}
+
+ )
+}
+
+// TextArea
+type TextAreaProps = ComponentProps &
+ RailsTextArea &
+ InputProps
+export const TextArea = (props: TextAreaProps) => {
+ const { type, errorKey, label, rows, ...rest } = props
+
+ const errorMessage = useErrorMessage(errorKey)
+
+ return (
+
+ )
+}
+
+type FileFieldProps = RailsFileField & InputProps
+export const FileField = (props: FileFieldProps) => {
+ const { type, errorKey, label, ...rest } = props
+
+ const errorMessage = useErrorMessage(errorKey)
+
+ return (
+
+ )
+}
+
+// SubmitButton
+type SubmitButtonProps = ComponentProps & RailsSubmitButton
+export const SubmitButton = (props: SubmitButtonProps) => {
+ const { type, text, ...rest } = props
+
+ return (
+
+ )
+}
diff --git a/wrappers/ts/mui/v9/package.json b/wrappers/ts/mui/v9/package.json
new file mode 100644
index 0000000..a53c8e6
--- /dev/null
+++ b/wrappers/ts/mui/v9/package.json
@@ -0,0 +1,13 @@
+{
+ "name": "@candy_wrapper/mui-v9",
+ "private": true,
+ "scripts": {
+ "typecheck": "tsc --noEmit",
+ "build": "tsc --project tsconfig.build.json && mkdir -p ../../../../wrappers/js/mui/v9 && cp dist/wrappers/ts/mui/v9/index.jsx ../../../../wrappers/js/mui/v9/index.jsx && rm -rf dist"
+ },
+ "devDependencies": {
+ "@mui/material": "^9.0.0",
+ "@emotion/react": "^11.0.0",
+ "@emotion/styled": "^11.0.0"
+ }
+}
diff --git a/wrappers/ts/mui/v9/tsconfig.build.json b/wrappers/ts/mui/v9/tsconfig.build.json
new file mode 100644
index 0000000..34f2413
--- /dev/null
+++ b/wrappers/ts/mui/v9/tsconfig.build.json
@@ -0,0 +1,14 @@
+{
+ "extends": "../../../../tsconfig",
+ "compilerOptions": {
+ "removeComments": false,
+ "declaration": false,
+ "moduleResolution": "bundler",
+ "skipLibCheck": true,
+ "outDir": "./dist",
+ "paths": {
+ "@thoughtbot/candy_wrapper": ["../../../../src/index"]
+ }
+ },
+ "include": ["./index.tsx"]
+}
diff --git a/wrappers/ts/mui/v9/tsconfig.json b/wrappers/ts/mui/v9/tsconfig.json
new file mode 100644
index 0000000..3ddf145
--- /dev/null
+++ b/wrappers/ts/mui/v9/tsconfig.json
@@ -0,0 +1,10 @@
+{
+ "extends": "../../../../tsconfig",
+ "compilerOptions": {
+ "noEmit": true,
+ "paths": {
+ "@thoughtbot/candy_wrapper": ["../../../../src/index"]
+ }
+ },
+ "include": ["./**/*.ts", "./**/*.tsx"]
+}
diff --git a/wrappers/ts/react-spectrum/s2/Checkbox.test.jsx b/wrappers/ts/react-spectrum/s2/Checkbox.test.jsx
new file mode 100644
index 0000000..9104369
--- /dev/null
+++ b/wrappers/ts/react-spectrum/s2/Checkbox.test.jsx
@@ -0,0 +1,107 @@
+import React from 'react'
+import { render } from '@testing-library/react'
+import { Checkbox, ValidationContext } from '.'
+
+const buildPayload = () => {
+ return {
+ type: 'checkbox',
+ value: '1',
+ uncheckedValue: '0',
+ defaultChecked: true,
+ name: 'post[admin]',
+ id: 'post_admin',
+ }
+}
+
+describe('Checkbox', () => {
+ it('renders', () => {
+ const payload = buildPayload()
+
+ const { getByRole } = render()
+
+ const element = getByRole('checkbox', { name: 'Is admin' })
+ expect(element).not.toBeNull()
+ })
+
+ it('renders defaultSelected', () => {
+ const payload = buildPayload()
+ payload.defaultChecked = true
+
+ const { getByRole } = render(
+
+ )
+
+ const input = getByRole('checkbox', { name: 'Is admin' })
+ expect(input.checked).toEqual(true)
+ })
+
+ it('renders defaultSelected false', () => {
+ const payload = buildPayload()
+ payload.defaultChecked = false
+
+ const { getByRole } = render(
+
+ )
+
+ const input = getByRole('checkbox', { name: 'Is admin' })
+ expect(input.checked).toEqual(false)
+ })
+
+ it('renders selected', () => {
+ const payload = buildPayload()
+ delete payload.defaultChecked
+ payload.checked = true
+
+ const { getByRole } = render(
+
+ )
+
+ const input = getByRole('checkbox', { name: 'Is admin' })
+ expect(input.checked).toEqual(true)
+ })
+
+ it('renders unselected', () => {
+ const payload = buildPayload()
+ delete payload.defaultChecked
+ payload.checked = false
+
+ const { getByRole } = render(
+
+ )
+
+ const input = getByRole('checkbox', { name: 'Is admin' })
+ expect(input.checked).toEqual(false)
+ })
+
+ it('adds a hidden input if includeHidden is true', () => {
+ const payload = buildPayload()
+ payload.includeHidden = true
+ payload.uncheckedValue = '10'
+
+ const { container } = render(
+
+ )
+
+ const hiddenInput = container.querySelector('input[type=hidden]')
+ expect(hiddenInput).not.toBe(null)
+ expect(hiddenInput.name).toEqual('post[admin]')
+ expect(hiddenInput.value).toEqual('10')
+ })
+
+ it('renders with errors', async () => {
+ const payload = buildPayload()
+
+ const validationErrors = {
+ is_admin: 'Admin invalid',
+ }
+
+ const { container } = render(
+
+
+
+ )
+
+ const checkbox = container.querySelector('[aria-invalid="true"]')
+ expect(checkbox).not.toBeNull()
+ })
+})
diff --git a/wrappers/ts/react-spectrum/s2/CollectionCheckboxes.test.jsx b/wrappers/ts/react-spectrum/s2/CollectionCheckboxes.test.jsx
new file mode 100644
index 0000000..fb18c8f
--- /dev/null
+++ b/wrappers/ts/react-spectrum/s2/CollectionCheckboxes.test.jsx
@@ -0,0 +1,102 @@
+import React from 'react'
+import { render } from '@testing-library/react'
+import { CollectionCheckboxes, ValidationContext } from '.'
+
+const buildCheckboxPayload = (value, label) => {
+ return {
+ type: 'checkbox',
+ value,
+ defaultChecked: true,
+ label,
+ name: 'post[author_ids][]',
+ id: `post_author_ids_${value}`,
+ }
+}
+
+const buildPayload = () => {
+ return {
+ collection: [
+ buildCheckboxPayload(1, 'one'),
+ buildCheckboxPayload(2, 'two'),
+ ],
+ includeHidden: true,
+ label: 'authors',
+ required: false,
+ }
+}
+
+describe('CollectionCheckboxes', () => {
+ it('renders', () => {
+ const payload = buildPayload()
+
+ const { getByRole } = render(
+
+ )
+
+ const group = getByRole('group', { name: 'Authors' })
+ expect(group).not.toBeNull()
+
+ const checkbox1 = getByRole('checkbox', { name: 'one' })
+ expect(checkbox1).not.toBeNull()
+
+ const checkbox2 = getByRole('checkbox', { name: 'two' })
+ expect(checkbox2).not.toBeNull()
+ })
+
+ it('renders nothing when the collection is blank', () => {
+ const payload = buildPayload()
+ payload.collection = []
+
+ const { container } = render(
+
+ )
+
+ expect(container.innerHTML).toEqual('')
+ })
+
+ it('renders hidden', () => {
+ const payload = buildPayload()
+ payload.includeHidden = true
+
+ const { container } = render(
+
+ )
+
+ const hiddenInput = container.querySelector('input[type=hidden]')
+ expect(hiddenInput.name).toEqual('post[author_ids][]')
+ expect(hiddenInput.value).toEqual('')
+ })
+
+ it('renders with error highlighting', async () => {
+ const payload = buildPayload()
+
+ const validationErrors = {
+ author_ids: 'id invalid',
+ }
+
+ const { getByText } = render(
+
+
+
+ )
+
+ const errorField = getByText('id invalid')
+ expect(errorField).not.toBeNull()
+ })
+})
diff --git a/wrappers/ts/react-spectrum/s2/CollectionRadioButtons.test.jsx b/wrappers/ts/react-spectrum/s2/CollectionRadioButtons.test.jsx
new file mode 100644
index 0000000..b6f0e3b
--- /dev/null
+++ b/wrappers/ts/react-spectrum/s2/CollectionRadioButtons.test.jsx
@@ -0,0 +1,102 @@
+import React from 'react'
+import { render } from '@testing-library/react'
+import { CollectionRadioButtons, ValidationContext } from '.'
+
+const buildRadioButtonPayload = (value, label, rest) => {
+ return {
+ type: 'radio',
+ value,
+ label,
+ name: 'post[subscribe]',
+ id: `post_subscribe_${value}`,
+ ...rest,
+ }
+}
+
+const buildPayload = () => {
+ return {
+ collection: [
+ buildRadioButtonPayload(1, 'one', { defaultChecked: true }),
+ buildRadioButtonPayload(2, 'two'),
+ ],
+ includeHidden: true,
+ label: 'Subscribe',
+ required: false,
+ }
+}
+
+describe('CollectionRadioButtons', () => {
+ it('renders', () => {
+ const payload = buildPayload()
+
+ const { getByRole } = render(
+
+ )
+
+ const radioGroup = getByRole('radiogroup', { name: 'Subscribe' })
+ expect(radioGroup).not.toBeNull()
+
+ const radio1 = getByRole('radio', { name: 'one' })
+ expect(radio1).not.toBeNull()
+
+ const radio2 = getByRole('radio', { name: 'two' })
+ expect(radio2).not.toBeNull()
+ })
+
+ it('renders nothing when the collection is blank', () => {
+ const payload = buildPayload()
+ payload.collection = []
+
+ const { container } = render(
+
+ )
+
+ expect(container.innerHTML).toEqual('')
+ })
+
+ it('renders hidden', () => {
+ const payload = buildPayload()
+ payload.includeHidden = true
+
+ const { container } = render(
+
+ )
+
+ const hiddenInput = container.querySelector('input[type=hidden]')
+ expect(hiddenInput.name).toEqual('post[subscribe]')
+ expect(hiddenInput.value).toEqual('')
+ })
+
+ it('renders with error highlighting', async () => {
+ const payload = buildPayload()
+
+ const validationErrors = {
+ subscribe: 'id invalid',
+ }
+
+ const { getByText } = render(
+
+
+
+ )
+
+ const errorField = getByText('id invalid')
+ expect(errorField).not.toBeNull()
+ })
+})
diff --git a/wrappers/ts/react-spectrum/s2/ColorField.test.jsx b/wrappers/ts/react-spectrum/s2/ColorField.test.jsx
new file mode 100644
index 0000000..979abb3
--- /dev/null
+++ b/wrappers/ts/react-spectrum/s2/ColorField.test.jsx
@@ -0,0 +1,44 @@
+import React from 'react'
+import { render } from '@testing-library/react'
+import { ColorField, ValidationContext } from '.'
+
+const buildPayload = () => {
+ return {
+ type: 'color',
+ name: 'post[color]',
+ id: 'post_color',
+ required: false,
+ defaultValue: '#000000',
+ }
+}
+
+describe('ColorField', () => {
+ it('renders', () => {
+ const payload = buildPayload()
+
+ const { getByRole } = render(
+
+ )
+
+ const input = getByRole('textbox', { name: 'color' })
+ expect(input).not.toBeNull()
+ expect(input.value).toEqual('#000000')
+ })
+
+ it('renders with field errors', async () => {
+ const payload = buildPayload()
+
+ const validationErrors = {
+ color: 'color invalid',
+ }
+
+ const { getByText } = render(
+
+
+
+ )
+
+ const errorField = getByText('color invalid')
+ expect(errorField).not.toBeNull()
+ })
+})
diff --git a/wrappers/ts/react-spectrum/s2/DateField.test.jsx b/wrappers/ts/react-spectrum/s2/DateField.test.jsx
new file mode 100644
index 0000000..de5551e
--- /dev/null
+++ b/wrappers/ts/react-spectrum/s2/DateField.test.jsx
@@ -0,0 +1,44 @@
+import React from 'react'
+import { render } from '@testing-library/react'
+import { DateField, ValidationContext } from '.'
+
+const buildPayload = () => {
+ return {
+ type: 'date',
+ defaultValue: '2004-06-15',
+ max: '2010-08-15',
+ min: '2000-06-15',
+ name: 'post[birth_date]',
+ id: 'post_birth_date',
+ }
+}
+
+describe('DateField', () => {
+ it('renders', () => {
+ const payload = buildPayload()
+
+ const { getByRole } = render(
+
+ )
+
+ const group = getByRole('group', { name: 'Birth Date' })
+ expect(group).not.toBeNull()
+ })
+
+ it('renders with field errors', async () => {
+ const payload = buildPayload()
+
+ const validationErrors = {
+ birth_date: 'birth invalid',
+ }
+
+ const { getByText } = render(
+
+
+
+ )
+
+ const errorField = getByText('birth invalid')
+ expect(errorField).not.toBeNull()
+ })
+})
diff --git a/wrappers/ts/react-spectrum/s2/DateTimeLocalField.test.jsx b/wrappers/ts/react-spectrum/s2/DateTimeLocalField.test.jsx
new file mode 100644
index 0000000..b0e5e6f
--- /dev/null
+++ b/wrappers/ts/react-spectrum/s2/DateTimeLocalField.test.jsx
@@ -0,0 +1,52 @@
+import React from 'react'
+import { render } from '@testing-library/react'
+import { DateTimeLocalField, ValidationContext } from '.'
+
+const buildPayload = () => {
+ return {
+ type: 'datetime-local',
+ defaultValue: '2004-06-15T01:02:03',
+ max: '2010-08-15T00:00:00',
+ min: '2000-06-15T00:00:00',
+ name: 'post[birth_date]',
+ id: 'post_birth_date',
+ }
+}
+
+describe('DateTimeLocalField', () => {
+ it('renders', () => {
+ const payload = buildPayload()
+
+ const { getByText } = render(
+
+ )
+
+ const label = getByText('Birth Date')
+ expect(label).not.toBeNull()
+ })
+
+ it('renders with field errors', async () => {
+ const payload = buildPayload()
+
+ const validationErrors = {
+ birth_date: 'birth invalid',
+ }
+
+ const { getByText } = render(
+
+
+
+ )
+
+ const errorField = getByText('birth invalid')
+ expect(errorField).not.toBeNull()
+ })
+})
diff --git a/wrappers/ts/react-spectrum/s2/EmailField.test.jsx b/wrappers/ts/react-spectrum/s2/EmailField.test.jsx
new file mode 100644
index 0000000..150c019
--- /dev/null
+++ b/wrappers/ts/react-spectrum/s2/EmailField.test.jsx
@@ -0,0 +1,44 @@
+import React from 'react'
+import { render } from '@testing-library/react'
+import { EmailField, ValidationContext } from '.'
+
+const buildPayload = () => {
+ return {
+ type: 'email',
+ name: 'post[email]',
+ id: 'post_email',
+ required: false,
+ defaultValue: 'john@smith.com',
+ }
+}
+
+describe('EmailField', () => {
+ it('renders', () => {
+ const payload = buildPayload()
+
+ const { getByRole } = render(
+
+ )
+
+ const input = getByRole('textbox', { name: 'Email' })
+ expect(input).not.toBeNull()
+ expect(input.value).toEqual('john@smith.com')
+ })
+
+ it('renders with field errors', async () => {
+ const payload = buildPayload()
+
+ const validationErrors = {
+ email: 'email invalid',
+ }
+
+ const { getByText } = render(
+
+
+
+ )
+
+ const errorField = getByText('email invalid')
+ expect(errorField).not.toBeNull()
+ })
+})
diff --git a/wrappers/ts/react-spectrum/s2/FileField.test.jsx b/wrappers/ts/react-spectrum/s2/FileField.test.jsx
new file mode 100644
index 0000000..a23a756
--- /dev/null
+++ b/wrappers/ts/react-spectrum/s2/FileField.test.jsx
@@ -0,0 +1,42 @@
+import React from 'react'
+import { render } from '@testing-library/react'
+import { FileField, ValidationContext } from '.'
+
+const buildPayload = () => {
+ return {
+ type: 'file',
+ name: 'post[attachment]',
+ id: 'post_attachment',
+ required: false,
+ }
+}
+
+describe('FileField', () => {
+ it('renders', () => {
+ const payload = buildPayload()
+
+ const { getByRole } = render(
+
+ )
+
+ const button = getByRole('button', { name: 'attachment' })
+ expect(button).not.toBeNull()
+ })
+
+ it('renders with field errors', async () => {
+ const payload = buildPayload()
+
+ const validationErrors = {
+ attachment: 'attachment invalid',
+ }
+
+ const { getByText } = render(
+
+
+
+ )
+
+ const errorField = getByText('attachment invalid')
+ expect(errorField).not.toBeNull()
+ })
+})
diff --git a/wrappers/ts/react-spectrum/s2/MonthField.test.jsx b/wrappers/ts/react-spectrum/s2/MonthField.test.jsx
new file mode 100644
index 0000000..7da26b7
--- /dev/null
+++ b/wrappers/ts/react-spectrum/s2/MonthField.test.jsx
@@ -0,0 +1,44 @@
+import React from 'react'
+import { render } from '@testing-library/react'
+import { MonthField, ValidationContext } from '.'
+
+const buildPayload = () => {
+ return {
+ type: 'month',
+ defaultValue: '2004-06',
+ name: 'post[written_on]',
+ id: 'post_written_on',
+ }
+}
+
+describe('MonthField', () => {
+ it('renders', () => {
+ const payload = buildPayload()
+
+ const { container, getByText } = render(
+
+ )
+
+ getByText('Month')
+ const input = container.querySelector('input[type="month"]')
+ expect(input).not.toBeNull()
+ expect(input.value).toEqual('2004-06')
+ })
+
+ it('renders with field errors', async () => {
+ const payload = buildPayload()
+
+ const validationErrors = {
+ month: 'Month invalid',
+ }
+
+ const { getByText } = render(
+
+
+
+ )
+
+ const errorField = getByText('Month invalid')
+ expect(errorField).not.toBeNull()
+ })
+})
diff --git a/wrappers/ts/react-spectrum/s2/NumberField.test.jsx b/wrappers/ts/react-spectrum/s2/NumberField.test.jsx
new file mode 100644
index 0000000..5191f95
--- /dev/null
+++ b/wrappers/ts/react-spectrum/s2/NumberField.test.jsx
@@ -0,0 +1,51 @@
+import React from 'react'
+import { render } from '@testing-library/react'
+import { NumberField, ValidationContext } from '.'
+
+const buildPayload = () => {
+ return {
+ type: 'number',
+ defaultValue: '3',
+ name: 'post[favs]',
+ min: 1,
+ max: 9,
+ id: 'post_favs',
+ step: 2,
+ }
+}
+
+describe('NumberField', () => {
+ it('renders', () => {
+ const payload = buildPayload()
+
+ const { getByRole, container } = render(
+
+ )
+
+ const input = getByRole('textbox', { name: 'Favs' })
+ expect(input).not.toBeNull()
+
+ const hiddenInput = container.querySelector(
+ 'input[type=hidden][name="post[favs]"]'
+ )
+ expect(hiddenInput).not.toBeNull()
+ expect(hiddenInput.value).toEqual('3')
+ })
+
+ it('renders with field errors', async () => {
+ const payload = buildPayload()
+
+ const validationErrors = {
+ favs: 'favs invalid',
+ }
+
+ const { getByText } = render(
+
+
+
+ )
+
+ const errorField = getByText('favs invalid')
+ expect(errorField).not.toBeNull()
+ })
+})
diff --git a/wrappers/ts/react-spectrum/s2/PasswordField.test.jsx b/wrappers/ts/react-spectrum/s2/PasswordField.test.jsx
new file mode 100644
index 0000000..311550d
--- /dev/null
+++ b/wrappers/ts/react-spectrum/s2/PasswordField.test.jsx
@@ -0,0 +1,44 @@
+import React from 'react'
+import { render } from '@testing-library/react'
+import { PasswordField, ValidationContext } from '.'
+
+const buildPayload = () => {
+ return {
+ type: 'password',
+ name: 'post[password]',
+ id: 'post_password',
+ required: false,
+ defaultValue: 'Password123',
+ }
+}
+
+describe('PasswordField', () => {
+ it('renders', () => {
+ const payload = buildPayload()
+
+ const { container } = render(
+
+ )
+
+ const input = container.querySelector('input[type="password"]')
+ expect(input).not.toBeNull()
+ expect(input.value).toEqual('Password123')
+ })
+
+ it('renders with field errors', async () => {
+ const payload = buildPayload()
+
+ const validationErrors = {
+ password: 'Does not match',
+ }
+
+ const { getByText } = render(
+
+
+
+ )
+
+ const errorField = getByText('Does not match')
+ expect(errorField).not.toBeNull()
+ })
+})
diff --git a/wrappers/ts/react-spectrum/s2/RangeField.test.jsx b/wrappers/ts/react-spectrum/s2/RangeField.test.jsx
new file mode 100644
index 0000000..2cb7cab
--- /dev/null
+++ b/wrappers/ts/react-spectrum/s2/RangeField.test.jsx
@@ -0,0 +1,50 @@
+import React from 'react'
+import { render } from '@testing-library/react'
+import { RangeField, ValidationContext } from '.'
+
+const buildPayload = () => {
+ return {
+ type: 'range',
+ defaultValue: '2',
+ name: 'post[volume]',
+ min: 1,
+ max: 9,
+ id: 'post_volume',
+ step: 2,
+ }
+}
+
+describe('RangeField', () => {
+ it('renders', () => {
+ const payload = buildPayload()
+
+ const { getByRole, container } = render(
+
+ )
+
+ const slider = getByRole('slider', { name: 'Volume' })
+ expect(slider).not.toBeNull()
+
+ const hiddenInput = container.querySelector('input[type="hidden"]')
+ expect(hiddenInput).not.toBeNull()
+ expect(hiddenInput.name).toEqual('post[volume]')
+ expect(hiddenInput.value).toEqual('2')
+ })
+
+ it('renders with field errors', async () => {
+ const payload = buildPayload()
+
+ const validationErrors = {
+ volume: 'volume invalid',
+ }
+
+ const { getByText } = render(
+
+
+
+ )
+
+ const errorField = getByText('volume invalid')
+ expect(errorField).not.toBeNull()
+ })
+})
diff --git a/wrappers/ts/react-spectrum/s2/SearchField.test.jsx b/wrappers/ts/react-spectrum/s2/SearchField.test.jsx
new file mode 100644
index 0000000..8090bff
--- /dev/null
+++ b/wrappers/ts/react-spectrum/s2/SearchField.test.jsx
@@ -0,0 +1,46 @@
+import React from 'react'
+import { render } from '@testing-library/react'
+import { SearchField, ValidationContext } from '.'
+
+const buildPayload = () => {
+ return {
+ type: 'search',
+ defaultValue: 'Hello World',
+ name: 'post[search]',
+ id: 'post_search',
+ }
+}
+
+describe('SearchField', () => {
+ it('renders', () => {
+ const payload = buildPayload()
+
+ const { getByRole } = render(
+
+ )
+
+ const input = getByRole('searchbox', { name: 'Search Posts' })
+ expect(input).not.toBeNull()
+ expect(input.value).toEqual('Hello World')
+ })
+
+ it('renders with field errors', async () => {
+ const payload = buildPayload()
+
+ const validationErrors = {
+ search: 'title invalid',
+ }
+
+ const { getByText, getByRole } = render(
+
+
+
+ )
+
+ const errorField = getByText('title invalid')
+ expect(errorField).not.toBeNull()
+
+ const input = getByRole('searchbox', { name: 'Search Posts' })
+ expect(input.value).toEqual('Hello World')
+ })
+})
diff --git a/wrappers/ts/react-spectrum/s2/Select.test.jsx b/wrappers/ts/react-spectrum/s2/Select.test.jsx
new file mode 100644
index 0000000..55c2d54
--- /dev/null
+++ b/wrappers/ts/react-spectrum/s2/Select.test.jsx
@@ -0,0 +1,89 @@
+import React from 'react'
+import { render } from '@testing-library/react'
+import { Select, ValidationContext } from '.'
+
+const buildPayload = () => {
+ return {
+ type: 'select',
+ name: 'post[category]',
+ id: 'post_category',
+ defaultValue: '',
+ options: [
+ { value: 'abe', label: 'abe' },
+ { value: '', label: '' },
+ { value: 'hest', label: 'hest' },
+ ],
+ }
+}
+
+describe('Select', () => {
+ describe('rendering', () => {
+ it('renders a Picker with the correct label', () => {
+ const payload = buildPayload()
+
+ const { getByRole } = render(
+
+ )
+
+ const button = getByRole('button', { name: /category/ })
+ expect(button).not.toBeNull()
+ })
+
+ it('adds a hidden input on multiple selects if includeHidden is true', () => {
+ const payload = buildPayload()
+ payload.multiple = true
+ payload.defaultValue = [payload.defaultValue]
+ payload.includeHidden = true
+
+ const { container } = render(
+
+ )
+
+ const hiddenInput = container.querySelector('input[type=hidden]')
+ expect(hiddenInput).not.toBe(null)
+ expect(hiddenInput.name).toEqual('post[category]')
+ })
+
+ it('excludes a hidden input on multiple selects if includeHidden is false', () => {
+ const payload = buildPayload()
+ payload.multiple = false
+ payload.includeHidden = false
+
+ const { container } = render(
+
+ )
+
+ const hiddenInput = container.querySelector(
+ 'input[type=hidden][name="post[category]"]'
+ )
+ expect(hiddenInput).toBe(null)
+ })
+
+ it('renders with nested options (groups)', () => {
+ const payload = {
+ type: 'select',
+ name: 'post[category]',
+ id: 'post_category',
+ includeHidden: false,
+ options: [
+ { value: 'abe', label: 'abe' },
+ {
+ label: 'sports',
+ options: [
+ { value: 'soccer', label: 'Soccer' },
+ { value: 'baseball', label: 'Baseball' },
+ ],
+ },
+ { value: 'hest', label: 'hest' },
+ ],
+ }
+
+ const { getByRole } = render(
+
+ )
+
+ const button = getByRole('button', { name: /category/ })
+ expect(button).not.toBeNull()
+ })
+ })
+})
diff --git a/wrappers/ts/react-spectrum/s2/TelField.test.jsx b/wrappers/ts/react-spectrum/s2/TelField.test.jsx
new file mode 100644
index 0000000..2bec655
--- /dev/null
+++ b/wrappers/ts/react-spectrum/s2/TelField.test.jsx
@@ -0,0 +1,44 @@
+import React from 'react'
+import { render } from '@testing-library/react'
+import { TelField, ValidationContext } from '.'
+
+const buildPayload = () => {
+ return {
+ type: 'tel',
+ name: 'post[phone]',
+ id: 'post_phone',
+ required: false,
+ defaultValue: '555-1234',
+ }
+}
+
+describe('TelField', () => {
+ it('renders', () => {
+ const payload = buildPayload()
+
+ const { getByRole } = render(
+
+ )
+
+ const input = getByRole('textbox', { name: 'phone' })
+ expect(input).not.toBeNull()
+ expect(input.value).toEqual('555-1234')
+ })
+
+ it('renders with field errors', async () => {
+ const payload = buildPayload()
+
+ const validationErrors = {
+ phone_number: 'phone number invalid',
+ }
+
+ const { getByText } = render(
+
+
+
+ )
+
+ const errorField = getByText('phone number invalid')
+ expect(errorField).not.toBeNull()
+ })
+})
diff --git a/wrappers/ts/react-spectrum/s2/TextArea.test.jsx b/wrappers/ts/react-spectrum/s2/TextArea.test.jsx
new file mode 100644
index 0000000..2c5997a
--- /dev/null
+++ b/wrappers/ts/react-spectrum/s2/TextArea.test.jsx
@@ -0,0 +1,47 @@
+import React from 'react'
+import { render } from '@testing-library/react'
+import { TextArea, ValidationContext } from '.'
+
+const buildPayload = () => {
+ return {
+ type: 'text',
+ name: 'post[category]',
+ id: 'post_category',
+ required: false,
+ defaultValue: 'books',
+ }
+}
+
+describe('TextArea', () => {
+ it('renders', () => {
+ const payload = buildPayload()
+
+ const { getByRole } = render(
+
+ )
+
+ const input = getByRole('textbox', { name: 'category' })
+ expect(input).not.toBeNull()
+ expect(input.value).toEqual('books')
+ })
+
+ it('renders with field errors', async () => {
+ const payload = buildPayload()
+
+ const validationErrors = {
+ category: 'title invalid',
+ }
+
+ const { getByText, getByRole } = render(
+
+
+
+ )
+
+ const errorField = getByText('title invalid')
+ expect(errorField).not.toBeNull()
+
+ const input = getByRole('textbox', { name: 'category' })
+ expect(input.value).toEqual('books')
+ })
+})
diff --git a/wrappers/ts/react-spectrum/s2/TextField.test.jsx b/wrappers/ts/react-spectrum/s2/TextField.test.jsx
new file mode 100644
index 0000000..e2be832
--- /dev/null
+++ b/wrappers/ts/react-spectrum/s2/TextField.test.jsx
@@ -0,0 +1,47 @@
+import React from 'react'
+import { render } from '@testing-library/react'
+import { TextField, ValidationContext } from '.'
+
+const buildPayload = () => {
+ return {
+ type: 'text',
+ name: 'post[category]',
+ id: 'post_category',
+ required: false,
+ defaultValue: 'books',
+ }
+}
+
+describe('TextField', () => {
+ it('renders', () => {
+ const payload = buildPayload()
+
+ const { getByRole } = render(
+
+ )
+
+ const input = getByRole('textbox', { name: 'category' })
+ expect(input).not.toBeNull()
+ expect(input.value).toEqual('books')
+ })
+
+ it('renders with field errors', async () => {
+ const payload = buildPayload()
+
+ const validationErrors = {
+ category: 'title invalid',
+ }
+
+ const { getByText, getByRole } = render(
+
+
+
+ )
+
+ const errorField = getByText('title invalid')
+ expect(errorField).not.toBeNull()
+
+ const input = getByRole('textbox', { name: 'category' })
+ expect(input.value).toEqual('books')
+ })
+})
diff --git a/wrappers/ts/react-spectrum/s2/TimeField.test.jsx b/wrappers/ts/react-spectrum/s2/TimeField.test.jsx
new file mode 100644
index 0000000..b0d3ed4
--- /dev/null
+++ b/wrappers/ts/react-spectrum/s2/TimeField.test.jsx
@@ -0,0 +1,42 @@
+import React from 'react'
+import { render } from '@testing-library/react'
+import { TimeField, ValidationContext } from '.'
+
+const buildPayload = () => {
+ return {
+ type: 'time',
+ defaultValue: '15:02:00',
+ name: 'post[birth_time]',
+ id: 'post_birth_time',
+ }
+}
+
+describe('TimeField', () => {
+ it('renders', () => {
+ const payload = buildPayload()
+
+ const { getByRole } = render(
+
+ )
+
+ const group = getByRole('group', { name: 'Birth Time' })
+ expect(group).not.toBeNull()
+ })
+
+ it('renders with field errors', async () => {
+ const payload = buildPayload()
+
+ const validationErrors = {
+ birth_time: 'Birth Time invalid',
+ }
+
+ const { getByText } = render(
+
+
+
+ )
+
+ const errorField = getByText('Birth Time invalid')
+ expect(errorField).not.toBeNull()
+ })
+})
diff --git a/wrappers/ts/react-spectrum/s2/UrlField.test.jsx b/wrappers/ts/react-spectrum/s2/UrlField.test.jsx
new file mode 100644
index 0000000..3b89cf4
--- /dev/null
+++ b/wrappers/ts/react-spectrum/s2/UrlField.test.jsx
@@ -0,0 +1,44 @@
+import React from 'react'
+import { render } from '@testing-library/react'
+import { UrlField, ValidationContext } from '.'
+
+const buildPayload = () => {
+ return {
+ type: 'url',
+ name: 'post[url]',
+ id: 'post_url',
+ required: false,
+ defaultValue: 'https://google.com',
+ }
+}
+
+describe('UrlField', () => {
+ it('renders', () => {
+ const payload = buildPayload()
+
+ const { getByRole } = render(
+
+ )
+
+ const input = getByRole('textbox', { name: 'Url' })
+ expect(input).not.toBeNull()
+ expect(input.value).toEqual('https://google.com')
+ })
+
+ it('renders with field errors', async () => {
+ const payload = buildPayload()
+
+ const validationErrors = {
+ url: 'Url invalid',
+ }
+
+ const { getByText } = render(
+
+
+
+ )
+
+ const errorField = getByText('Url invalid')
+ expect(errorField).not.toBeNull()
+ })
+})
diff --git a/wrappers/ts/react-spectrum/s2/index.tsx b/wrappers/ts/react-spectrum/s2/index.tsx
new file mode 100644
index 0000000..e84e10b
--- /dev/null
+++ b/wrappers/ts/react-spectrum/s2/index.tsx
@@ -0,0 +1,820 @@
+/**
+ * A set of [candy_wrappers](https://github.com/thoughtbot/candy_wrapper) around
+ * React Spectrum S2 input components. It works with the output from
+ * [FormProps](https://github.com/thoughtbot/form_props).
+ *
+ * You modify these components to fit your design needs.
+ */
+
+import React, { createContext, useContext, useMemo } from 'react'
+import {
+ parseDate,
+ parseDateTime,
+ parseTime,
+ CalendarDate,
+ CalendarDateTime,
+ Time,
+} from '@internationalized/date'
+
+export type {
+ CheckboxField as RailsCheckboxFieldProps,
+ CollectionCheckboxesField as RailsCollectionCheckboxesFieldProps,
+ CollectionRadioButtonsField as RailsCollectionRadioButtonsFieldProps,
+ ColorField as RailsColorFieldProps,
+ DateField as RailsDateFieldProps,
+ DateTimeLocalField as RailsDateTimeLocalFieldProps,
+ EmailField as RailsEmailFieldProps,
+ FileField as RailsFileFieldProps,
+ HiddenField as RailsHiddenFieldProps,
+ MonthField as RailsMonthFieldProps,
+ NumberField as RailsNumberFieldProps,
+ PasswordField as RailsPasswordFieldProps,
+ RangeField as RailsRangeFieldProps,
+ SearchField as RailsSearchFieldProps,
+ Select as RailsSelectProps,
+ SubmitProps as RailsSubmitButtonProps,
+ TelField as RailsTelFieldProps,
+ TextArea as RailsTextAreaProps,
+ TextField as RailsTextFieldProps,
+ TimeField as RailsTimeFieldProps,
+ UrlField as RailsUrlFieldProps,
+ HTMLFormProps as RailsHTMLFormProps,
+ ValidationErrors,
+} from '@thoughtbot/candy_wrapper'
+
+import {
+ CheckboxField as RailsCheckboxField,
+ CollectionCheckboxesField as RailsCollectionCheckboxesField,
+ CollectionRadioButtonsField as RailsCollectionRadioButtonsField,
+ ColorField as RailsColorField,
+ DateField as RailsDateField,
+ DateTimeLocalField as RailsDateTimeLocalField,
+ EmailField as RailsEmailField,
+ FileField as RailsFileField,
+ HiddenField as RailsHiddenField,
+ MonthField as RailsMonthField,
+ NumberField as RailsNumberField,
+ PasswordField as RailsPasswordField,
+ RangeField as RailsRangeField,
+ SearchField as RailsSearchField,
+ SingleSelect as RailsSingleSelect,
+ MultiSelect as RailsMultiSelect,
+ SubmitProps as RailsSubmitButton,
+ TelField as RailsTelField,
+ TextArea as RailsTextArea,
+ TextField as RailsTextField,
+ TimeField as RailsTimeField,
+ UrlField as RailsUrlField,
+ HTMLFormProps as RailsHTMLFormProps,
+ ValidationErrors,
+} from '@thoughtbot/candy_wrapper'
+
+import { TextField as SpectrumTextField } from '@react-spectrum/s2/TextField'
+import { TextArea as SpectrumTextArea } from '@react-spectrum/s2/TextArea'
+import { NumberField as SpectrumNumberField } from '@react-spectrum/s2/NumberField'
+import { DateField as SpectrumDateField } from '@react-spectrum/s2/DateField'
+import { TimeField as SpectrumTimeField } from '@react-spectrum/s2/TimeField'
+import { DatePicker as SpectrumDatePicker } from '@react-spectrum/s2/DatePicker'
+import { ColorField as SpectrumColorField } from '@react-spectrum/s2/ColorField'
+import { SearchField as SpectrumSearchField } from '@react-spectrum/s2/SearchField'
+import {
+ Checkbox as SpectrumCheckbox,
+} from '@react-spectrum/s2/Checkbox'
+import {
+ CheckboxGroup as SpectrumCheckboxGroup,
+} from '@react-spectrum/s2/CheckboxGroup'
+import {
+ RadioGroup as SpectrumRadioGroup,
+ Radio as SpectrumRadio,
+} from '@react-spectrum/s2/RadioGroup'
+import {
+ Picker as SpectrumPicker,
+ PickerItem,
+ PickerSection,
+} from '@react-spectrum/s2/Picker'
+import { Slider as SpectrumSlider } from '@react-spectrum/s2/Slider'
+import { Button as SpectrumButton } from '@react-spectrum/s2/Button'
+import { Form as SpectrumForm } from '@react-spectrum/s2/Form'
+import { FileTrigger as SpectrumFileTrigger } from '@react-spectrum/s2/FileTrigger'
+
+export const ValidationContext = createContext({})
+
+export const useErrorMessage = (errorKey?: string) => {
+ const errors = useContext(ValidationContext)
+
+ return useMemo(() => {
+ if (!errorKey) {
+ return null
+ }
+
+ const validationError = errors[errorKey]
+ const hasErrors = errorKey && validationError
+
+ if (!hasErrors) {
+ return null
+ }
+
+ const errorMessages = Array.isArray(validationError)
+ ? validationError
+ : [validationError]
+
+ return errorMessages.join(' ')
+ }, [errors, errorKey])
+}
+
+export type ExtrasProps = Record
+
+export const Extras = (hiddenInputAttributes: ExtrasProps) => {
+ const hiddenProps = Object.values(hiddenInputAttributes)
+ const hiddenInputs = hiddenProps.map((props: RailsHiddenField) => (
+
+ ))
+
+ return <>{hiddenInputs}>
+}
+
+export interface FormProps {
+ extras: ExtrasProps
+ inputs: T
+ form: RailsHTMLFormProps
+}
+
+type FormElementProps = React.FormHTMLAttributes & {
+ extras: ExtrasProps
+ validationErrors?: ValidationErrors
+}
+
+export const Form = ({
+ extras,
+ validationErrors = {},
+ children,
+ ...props
+}: FormElementProps) => {
+ const formProps = props as React.ComponentProps
+
+ return (
+
+
+
+ {children}
+
+
+ )
+}
+
+type InputProps = {
+ label: string
+ errorKey?: string
+}
+
+type CheckboxProps = RailsCheckboxField & InputProps
+
+export const Checkbox = ({
+ type: _type,
+ includeHidden,
+ uncheckedValue,
+ errorKey,
+ label,
+ checked,
+ defaultChecked,
+ ...rest
+}: CheckboxProps) => {
+ const errorMessage = useErrorMessage(errorKey)
+
+ // Transform: Rails checked/defaultChecked → Spectrum isSelected/defaultSelected
+ const isSelected = checked ?? undefined
+ const defaultSelected = defaultChecked ?? undefined
+
+ return (
+ <>
+ {includeHidden && (
+
+ )}
+
+ {label}
+
+ >
+ )
+}
+
+type CollectionCheckboxesFieldProps = RailsCollectionCheckboxesField &
+ InputProps
+
+export const CollectionCheckboxes = ({
+ includeHidden,
+ collection,
+ label,
+ errorKey,
+}: CollectionCheckboxesFieldProps) => {
+ const errorMessage = useErrorMessage(errorKey)
+
+ if (collection.length === 0) {
+ return null
+ }
+
+ const defaultItems = collection.filter((option) => !!option.defaultChecked)
+ const items = collection.filter((option) => !!option.checked)
+ const valueProps: { value?: string[]; defaultValue?: string[] } = {}
+ if (defaultItems.length > 0) {
+ valueProps.defaultValue = defaultItems.map((option) => option.value)
+ } else if (items.length > 0) {
+ valueProps.value = items.map((option) => option.value)
+ }
+
+ const { name } = collection[0]
+
+ return (
+ <>
+ {includeHidden && (
+
+ )}
+
+ {collection.map((option) => (
+
+ {option.label}
+
+ ))}
+
+ >
+ )
+}
+
+type CollectionRadioButtonsFieldProps = RailsCollectionRadioButtonsField &
+ InputProps
+
+export const CollectionRadioButtons = ({
+ includeHidden,
+ collection,
+ label,
+ errorKey,
+}: CollectionRadioButtonsFieldProps) => {
+ const errorMessage = useErrorMessage(errorKey)
+
+ if (collection.length === 0) {
+ return null
+ }
+
+ const defaultItem = collection.find((option) => !!option.defaultChecked)
+ const item = collection.find((option) => !!option.checked)
+ const valueProps: { value?: string; defaultValue?: string } = {}
+ if (defaultItem) {
+ valueProps.defaultValue = defaultItem.value
+ } else if (item) {
+ valueProps.value = item.value
+ }
+
+ const { name } = collection[0]
+
+ return (
+ <>
+ {includeHidden && (
+
+ )}
+
+ {collection.map((option) => (
+
+ {option.label}
+
+ ))}
+
+ >
+ )
+}
+
+export type TextFieldProps = RailsTextField & InputProps
+
+export const TextField = ({
+ type: _type,
+ label,
+ errorKey,
+ size: _size,
+ ...rest
+}: TextFieldProps) => {
+ const errorMessage = useErrorMessage(errorKey)
+
+ return (
+
+ )
+}
+
+export type EmailFieldProps = RailsEmailField & InputProps
+
+export const EmailField = ({
+ type: _type,
+ label,
+ errorKey,
+ size: _size,
+ ...rest
+}: EmailFieldProps) => {
+ const errorMessage = useErrorMessage(errorKey)
+
+ return (
+
+ )
+}
+
+export type ColorFieldProps = RailsColorField & InputProps
+
+export const ColorField = ({
+ type: _type,
+ label,
+ errorKey,
+ ...rest
+}: ColorFieldProps) => {
+ const errorMessage = useErrorMessage(errorKey)
+
+ return (
+
+ )
+}
+
+export type DateFieldProps = RailsDateField & InputProps
+
+export const DateField = ({
+ type: _type,
+ label,
+ errorKey,
+ value,
+ defaultValue,
+ min,
+ max,
+ ...rest
+}: DateFieldProps) => {
+ const errorMessage = useErrorMessage(errorKey)
+
+ // Transform: string values → CalendarDate objects
+ const valueProps: { value?: CalendarDate; defaultValue?: CalendarDate } = {}
+ if (value) {
+ valueProps.value = parseDate(value)
+ } else if (defaultValue) {
+ valueProps.defaultValue = parseDate(defaultValue)
+ }
+
+ // Transform: min/max → minValue/maxValue
+ const minMaxProps: {
+ minValue?: CalendarDate
+ maxValue?: CalendarDate
+ } = {}
+ if (min) {
+ minMaxProps.minValue = parseDate(min)
+ }
+ if (max) {
+ minMaxProps.maxValue = parseDate(max)
+ }
+
+ return (
+
+ )
+}
+
+export type DateTimeLocalFieldProps = RailsDateTimeLocalField & InputProps
+
+export const DateTimeLocalField = ({
+ type: _type,
+ label,
+ errorKey,
+ value,
+ defaultValue,
+ min,
+ max,
+ ...rest
+}: DateTimeLocalFieldProps) => {
+ const errorMessage = useErrorMessage(errorKey)
+
+ // Transform: string values → CalendarDateTime objects
+ const valueProps: {
+ value?: CalendarDateTime
+ defaultValue?: CalendarDateTime
+ } = {}
+ if (value) {
+ valueProps.value = parseDateTime(value)
+ } else if (defaultValue) {
+ valueProps.defaultValue = parseDateTime(defaultValue)
+ }
+
+ // Transform: min/max → minValue/maxValue
+ const minMaxProps: {
+ minValue?: CalendarDateTime
+ maxValue?: CalendarDateTime
+ } = {}
+ if (min) {
+ minMaxProps.minValue = parseDateTime(min)
+ }
+ if (max) {
+ minMaxProps.maxValue = parseDateTime(max)
+ }
+
+ return (
+
+ )
+}
+
+export type TimeFieldProps = RailsTimeField & InputProps
+
+export const TimeField = ({
+ type: _type,
+ label,
+ errorKey,
+ value,
+ defaultValue,
+ ...rest
+}: TimeFieldProps) => {
+ const errorMessage = useErrorMessage(errorKey)
+
+ // Transform: string values → Time objects
+ const valueProps: { value?: Time; defaultValue?: Time } = {}
+ if (value) {
+ valueProps.value = parseTime(value)
+ } else if (defaultValue) {
+ valueProps.defaultValue = parseTime(defaultValue)
+ }
+
+ return (
+
+ )
+}
+
+export type SearchFieldProps = RailsSearchField & InputProps
+
+export const SearchField = ({
+ type: _type,
+ label,
+ errorKey,
+ size: _size,
+ autosave: _autosave,
+ results: _results,
+ onsearch: _onsearch,
+ incremental: _incremental,
+ ...rest
+}: SearchFieldProps) => {
+ const errorMessage = useErrorMessage(errorKey)
+
+ return (
+
+ )
+}
+
+export type TelFieldProps = RailsTelField & InputProps
+
+export const TelField = ({
+ type: _type,
+ label,
+ errorKey,
+ size: _size,
+ ...rest
+}: TelFieldProps) => {
+ const errorMessage = useErrorMessage(errorKey)
+
+ return (
+
+ )
+}
+
+export type UrlFieldProps = RailsUrlField & InputProps
+
+export const UrlField = ({
+ type: _type,
+ label,
+ errorKey,
+ size: _size,
+ ...rest
+}: UrlFieldProps) => {
+ const errorMessage = useErrorMessage(errorKey)
+
+ return (
+
+ )
+}
+
+export type MonthFieldProps = RailsMonthField & InputProps
+
+export const MonthField = ({
+ type: _type,
+ label,
+ errorKey,
+ min: _min,
+ max: _max,
+ ...rest
+}: MonthFieldProps) => {
+ const errorMessage = useErrorMessage(errorKey)
+
+ return (
+
+ )
+}
+
+export type NumberFieldProps = RailsNumberField & InputProps
+
+export const NumberField = ({
+ type: _type,
+ label,
+ errorKey,
+ value,
+ defaultValue,
+ min,
+ max,
+ ...rest
+}: NumberFieldProps) => {
+ const errorMessage = useErrorMessage(errorKey)
+
+ // Transform: string/number values → number
+ const valueProps: { value?: number; defaultValue?: number } = {}
+ if (value !== undefined && value !== '') {
+ valueProps.value = Number(value)
+ } else if (defaultValue !== undefined && defaultValue !== '') {
+ valueProps.defaultValue = Number(defaultValue)
+ }
+
+ return (
+
+ )
+}
+
+export type RangeFieldProps = RailsRangeField & InputProps
+
+export const RangeField = ({
+ type: _type,
+ label,
+ errorKey,
+ value,
+ defaultValue,
+ min,
+ max,
+ ...rest
+}: RangeFieldProps) => {
+ const errorMessage = useErrorMessage(errorKey)
+
+ const numericValue = value ? Number(value) : undefined
+ const numericDefault = defaultValue ? Number(defaultValue) : undefined
+
+ return (
+ <>
+
+
+ {errorMessage && {errorMessage}}
+ >
+ )
+}
+
+export type PasswordFieldProps = RailsPasswordField & InputProps
+
+export const PasswordField = ({
+ type: _type,
+ label,
+ errorKey,
+ size: _size,
+ ...rest
+}: PasswordFieldProps) => {
+ const errorMessage = useErrorMessage(errorKey)
+
+ return (
+
+ )
+}
+
+type SelectProps = (RailsSingleSelect | RailsMultiSelect) & InputProps
+
+export const Select = ({
+ includeHidden,
+ name,
+ id,
+ options,
+ label,
+ errorKey,
+ multiple,
+ type: _type,
+ ...rest
+}: SelectProps) => {
+ const errorMessage = useErrorMessage(errorKey)
+ const addHidden = includeHidden && multiple
+
+ const selectedValue = 'value' in rest ? rest.value : undefined
+ const selectedDefault =
+ 'defaultValue' in rest ? rest.defaultValue : undefined
+
+ const hasGroups = options.some((item) => 'options' in item)
+
+ const selectionProps: Record = {}
+ if (multiple) {
+ selectionProps.selectionMode = 'multiple'
+ if (selectedValue) {
+ selectionProps.value = Array.isArray(selectedValue)
+ ? selectedValue
+ : [selectedValue]
+ } else if (selectedDefault) {
+ selectionProps.defaultValue = Array.isArray(selectedDefault)
+ ? selectedDefault
+ : [selectedDefault]
+ }
+ } else {
+ if (selectedValue) {
+ selectionProps.value = Array.isArray(selectedValue)
+ ? selectedValue[0]
+ : selectedValue
+ } else if (selectedDefault) {
+ selectionProps.defaultValue = Array.isArray(selectedDefault)
+ ? selectedDefault[0]
+ : selectedDefault
+ }
+ }
+
+ return (
+ <>
+ {addHidden && (
+
+ )}
+
+ {hasGroups
+ ? options.map((item) => {
+ if ('options' in item) {
+ return (
+
+ {item.options.map((opt) => (
+
+ {opt.label}
+
+ ))}
+
+ )
+ }
+ return (
+
+ {item.label}
+
+ )
+ })
+ : options.map((item) => {
+ if ('options' in item) {
+ return null
+ }
+ return (
+
+ {item.label}
+
+ )
+ })}
+
+ >
+ )
+}
+
+export type TextAreaProps = RailsTextArea & InputProps
+
+export const TextArea = ({
+ type: _type,
+ label,
+ errorKey,
+ ...rest
+}: TextAreaProps) => {
+ const errorMessage = useErrorMessage(errorKey)
+
+ return (
+
+ )
+}
+
+export type FileFieldProps = RailsFileField & InputProps
+
+export const FileField = ({ type: _type, label, errorKey }: FileFieldProps) => {
+ const errorMessage = useErrorMessage(errorKey)
+
+ return (
+ <>
+
+ {label}
+
+ {errorMessage && {errorMessage}}
+ >
+ )
+}
+
+export type SubmitButtonProps = RailsSubmitButton
+
+export const SubmitButton = ({
+ type: _type,
+ text,
+ ...rest
+}: SubmitButtonProps) => {
+ return (
+
+ {text}
+
+ )
+}
diff --git a/wrappers/ts/react-spectrum/s2/package.json b/wrappers/ts/react-spectrum/s2/package.json
new file mode 100644
index 0000000..1cf0487
--- /dev/null
+++ b/wrappers/ts/react-spectrum/s2/package.json
@@ -0,0 +1,12 @@
+{
+ "name": "@candy_wrapper/react-spectrum-s2",
+ "private": true,
+ "scripts": {
+ "typecheck": "tsc --noEmit",
+ "build": "tsc --project tsconfig.build.json && mkdir -p ../../../../wrappers/js/react-spectrum/s2 && cp dist/wrappers/ts/react-spectrum/s2/index.jsx ../../../../wrappers/js/react-spectrum/s2/index.jsx && rm -rf dist"
+ },
+ "devDependencies": {
+ "@react-spectrum/s2": "^1.6.0",
+ "@internationalized/date": "^3.0.0"
+ }
+}
diff --git a/wrappers/ts/react-spectrum/s2/tsconfig.build.json b/wrappers/ts/react-spectrum/s2/tsconfig.build.json
new file mode 100644
index 0000000..34f2413
--- /dev/null
+++ b/wrappers/ts/react-spectrum/s2/tsconfig.build.json
@@ -0,0 +1,14 @@
+{
+ "extends": "../../../../tsconfig",
+ "compilerOptions": {
+ "removeComments": false,
+ "declaration": false,
+ "moduleResolution": "bundler",
+ "skipLibCheck": true,
+ "outDir": "./dist",
+ "paths": {
+ "@thoughtbot/candy_wrapper": ["../../../../src/index"]
+ }
+ },
+ "include": ["./index.tsx"]
+}
diff --git a/wrappers/ts/react-spectrum/s2/tsconfig.json b/wrappers/ts/react-spectrum/s2/tsconfig.json
new file mode 100644
index 0000000..3ddf145
--- /dev/null
+++ b/wrappers/ts/react-spectrum/s2/tsconfig.json
@@ -0,0 +1,10 @@
+{
+ "extends": "../../../../tsconfig",
+ "compilerOptions": {
+ "noEmit": true,
+ "paths": {
+ "@thoughtbot/candy_wrapper": ["../../../../src/index"]
+ }
+ },
+ "include": ["./**/*.ts", "./**/*.tsx"]
+}