diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index cb4284c..635af24 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2,9 +2,9 @@ name: Test on: push: - branches: [main, development] + branches: [main] pull_request: - branches: [main, development] + branches: [main] workflow_dispatch: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3254563..b8f6633 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -16,3 +16,9 @@ repos: types_or: [javascript, jsx, ts, tsx] pass_filenames: false stages: [pre-commit] + + - id: build + name: Build + entry: npm run build + language: system + pass_filenames: false diff --git a/docs/changelog.md b/CHANGELOG.md similarity index 80% rename from docs/changelog.md rename to CHANGELOG.md index 8c51d33..ed96684 100644 --- a/docs/changelog.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ All notable changes to this project will be documented in this file. +## [Release 0.0.2] + +### Added + +- **Time Picker Field**: Introduced a fully functional "Time" field type with support across single page forms, flow builder, and previews. +- **Time & Date Constraints**: Added editable `minTime`, `maxTime`, `minDate`, and `maxDate` constraints directly within the Builder settings sidebar with easy "Clear" buttons. +- **SEO Optimizations**: Integrated comprehensive SEO tags, OpenGraph (`og:`) properties, and Twitter Card schemas directly into `index.html`. + +### Fixed + +- **Custom DatePicker UI**: Re-architected `DatePicker.tsx` to utilize a tailor-made, interactive dropdown calendar instead of native browser popups. + ## [Release 0.0.1] ### Added diff --git a/README.md b/README.md index bb2c734..17efb51 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ A powerful, modern form builder that combines the best of Google Forms, Zoho For - **Dual-Mode Builder**: Create forms in either "Single Page" or "Flow" (Typeform-style) layouts. - **Customizable Success Screen**: Personalize the "Thank You" experience with custom titles, descriptions, and emojis. - **Google Sheets Integration**: Automatically sync form responses to a linked Google Spreadsheet. -- **Rich Field Types**: Support for Text, TextArea, Radio, Checkbox, Select, Date, and File Upload. +- **Rich Field Types**: Support for Text, TextArea, Radio, Checkbox, Select, Date, Time, and File Upload. - **Advanced Validation**: Real-time validation for required fields, character limits, and file types. - **Locale-Aware Formatting**: Intelligent date and time formatting across all management pages. - **Data Export**: Export responses to CSV for easy analysis. diff --git a/docs/components.md b/docs/components.md index e9b02d7..01f771c 100644 --- a/docs/components.md +++ b/docs/components.md @@ -74,6 +74,34 @@ A textarea component support forwardRef. - `disabled`: boolean (optional) - **Ref**: Supports `forwardRef` +## DatePicker + +A highly customizable dropdown calendar component replacing the native browser ``. + +- `label`: string (required) +- `name`: string (optional) +- `value`: string (optional) +- `min`: string (optional) +- `max`: string (optional) +- `onChange`: (e: React.ChangeEvent) => void (optional) +- `disabled`: boolean (optional) +- `required`: boolean (optional) +- `subtitle`: string (optional) + +## TimePicker + +An interactive time selection component using transparent CSS overlays and DOM mutations for a superior clicking experience. + +- `label`: string (required) +- `name`: string (optional) +- `value`: string (optional) +- `min`: string (optional) +- `max`: string (optional) +- `onChange`: (e: React.ChangeEvent) => void (optional) +- `disabled`: boolean (optional) +- `required`: boolean (optional) +- `subtitle`: string (optional) + ## Icons A collection of SVG icons exported as the `Icons` object. @@ -91,6 +119,8 @@ A collection of SVG icons exported as the `Icons` object. - `Icons.Eye` - `Icons.ArrowLeft` - `Icons.Logic` + - `Icons.Calendar` + - `Icons.Clock` --- @@ -139,7 +169,7 @@ Modal component for previewing forms using `FlowPage` or `SinglePage` layouts. Core type definitions: - `FormMode` — `"single" | "flow"` -- `FieldType` — `"text" | "number" | "email" | "tel" | "radio" | "checkbox" | "textarea" | "select"` +- `FieldType` — `"text" | "number" | "email" | "tel" | "radio" | "checkbox" | "textarea" | "select" | "date" | "time" | "file"` - `FormFieldConfig` — Field configuration interface (now includes optional `logic`) - `ConditionalRule` — Logic rule definition (`operator`, `value`, `targetFieldId`) - `FieldLogic` — Container for field rules diff --git a/docs/forms.md b/docs/forms.md index a23277a..46ec1a7 100644 --- a/docs/forms.md +++ b/docs/forms.md @@ -10,13 +10,13 @@ Traditional single-page form layout where all fields are displayed at once. ### Props -| Prop | Type | Description | -| --------------- | ----------------------------------- | ------------------------------- | -| formTitle | string | Form title displayed at the top | -| formDescription | string | Form description/subtitle | +| Prop | Type | Description | +| --------------- | ----------------------------------- | ----------------------------------- | +| formTitle | string | Form title displayed at the top | +| formDescription | string | Form description/subtitle | | formBanner | string | URL for the banner image (optional) | -| fields | FormField[] | Array of form fields to render | -| onSubmit | (data: Record) => void | Callback when form is submitted | +| fields | FormField[] | Array of form fields to render | +| onSubmit | (data: Record) => void | Callback when form is submitted | ### Features @@ -87,11 +87,16 @@ interface FormField { | "textarea" | "select" | "date" + | "time" | "file"; title: string; subtitle?: string; placeholder?: string; maxLength?: number; + minDate?: string; + maxDate?: string; + minTime?: string; + maxTime?: string; options?: { label: string; value: string }[]; defaultValue?: string | boolean; /* Flow-only: Conditional branching */ diff --git a/index.html b/index.html index 827965b..ca79837 100644 --- a/index.html +++ b/index.html @@ -3,8 +3,46 @@ + + + + + + + + + + + + + + + + + + + + - Formify + Formify | Beautiful Drag & Drop Form Builder diff --git a/package.json b/package.json index accaf60..ef98c28 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,9 @@ { "name": "formify", + "description": "Form Builder", + "author": "qubitabhay", "private": true, - "version": "0.0.1", + "version": "0.0.2", "type": "module", "scripts": { "dev": "vite", diff --git a/src/components/BuilderCore/Flow/FlowCard.tsx b/src/components/BuilderCore/Flow/FlowCard.tsx index 9607163..b3b077f 100644 --- a/src/components/BuilderCore/Flow/FlowCard.tsx +++ b/src/components/BuilderCore/Flow/FlowCard.tsx @@ -8,6 +8,7 @@ import { memo } from "react"; import RadioButton from "../../common/RadioButton"; import Checkbox from "../../common/Checkbox"; import DatePicker from "../../common/DatePicker"; +import TimePicker from "../../common/TimePicker"; import FileUpload from "../../common/FileUpload"; interface FlowCardProps { @@ -112,6 +113,17 @@ const FlowCard = memo(function FlowCard({ /> ); + case "time": + return ( +
+ +
+ ); case "file": return (
diff --git a/src/components/BuilderCore/Single/DraggableField.tsx b/src/components/BuilderCore/Single/DraggableField.tsx index 93baba1..756d3eb 100644 --- a/src/components/BuilderCore/Single/DraggableField.tsx +++ b/src/components/BuilderCore/Single/DraggableField.tsx @@ -8,6 +8,7 @@ import Select from "../../common/Select"; import RadioButton from "../../common/RadioButton"; import Checkbox from "../../common/Checkbox"; import DatePicker from "../../common/DatePicker"; +import TimePicker from "../../common/TimePicker"; import FileUpload from "../../common/FileUpload"; interface DraggableFieldProps { @@ -107,6 +108,16 @@ const DraggableField = memo(function DraggableField({ disabled={true} /> ); + case "time": + return ( + + ); case "file": return ( )} + {field.type === "time" && ( +
+
+
+ + {field.minTime && ( + + )} +
+ onUpdate({ minTime: e.target.value })} + onFocus={(e) => { + e.target.type = "time"; + try { e.target.showPicker?.(); } catch { /* ignore */ } + }} + onBlur={(e) => { + if (!e.target.value) e.target.type = "text"; + }} + onClick={(e) => { + const target = e.target as HTMLInputElement; + if (target.type === "text") target.type = "time"; + try { target.showPicker?.(); } catch { /* ignore */ } + }} + className="w-full p-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-300 transition-all cursor-pointer [&::-webkit-calendar-picker-indicator]:cursor-pointer" + /> +
+
+
+ + {field.maxTime && ( + + )} +
+ onUpdate({ maxTime: e.target.value })} + onFocus={(e) => { + e.target.type = "time"; + try { e.target.showPicker?.(); } catch { /* ignore */ } + }} + onBlur={(e) => { + if (!e.target.value) e.target.type = "text"; + }} + onClick={(e) => { + const target = e.target as HTMLInputElement; + if (target.type === "text") target.type = "time"; + try { target.showPicker?.(); } catch { /* ignore */ } + }} + className="w-full p-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-300 transition-all cursor-pointer [&::-webkit-calendar-picker-indicator]:cursor-pointer" + /> +
+
+ )} +
- +
+ + {field.minDate && ( + + )} +
onUpdate({ minDate: e.target.value || undefined }) } - className="w-full p-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-300 focus:border-gray-300 outline-none transition-all text-sm" + onFocus={(e) => { + e.target.type = "date"; + try { e.target.showPicker?.(); } catch { /* ignore */ } + }} + onBlur={(e) => { + if (!e.target.value) e.target.type = "text"; + }} + onClick={(e) => { + const target = e.target as HTMLInputElement; + if (target.type === "text") target.type = "date"; + try { target.showPicker?.(); } catch { /* ignore */ } + }} + className="w-full p-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-300 focus:border-gray-300 outline-none transition-all text-sm cursor-pointer [&::-webkit-calendar-picker-indicator]:cursor-pointer" />
- +
+ + {field.maxDate && ( + + )} +
onUpdate({ maxDate: e.target.value || undefined }) } - className="w-full p-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-300 focus:border-gray-300 outline-none transition-all text-sm" + onFocus={(e) => { + e.target.type = "date"; + try { e.target.showPicker?.(); } catch { /* ignore */ } + }} + onBlur={(e) => { + if (!e.target.value) e.target.type = "text"; + }} + onClick={(e) => { + const target = e.target as HTMLInputElement; + if (target.type === "text") target.type = "date"; + try { target.showPicker?.(); } catch { /* ignore */ } + }} + className="w-full p-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-300 focus:border-gray-300 outline-none transition-all text-sm cursor-pointer [&::-webkit-calendar-picker-indicator]:cursor-pointer" />
diff --git a/src/components/BuilderCore/shared/constants.tsx b/src/components/BuilderCore/shared/constants.tsx index 0cecf9f..f54331f 100644 --- a/src/components/BuilderCore/shared/constants.tsx +++ b/src/components/BuilderCore/shared/constants.tsx @@ -91,6 +91,14 @@ export const FIELD_TEMPLATES: FieldTemplate[] = [ title: "Date", }, }, + { + type: "time", + label: "Time", + icon: , + defaultConfig: { + title: "Time", + }, + }, { type: "file", label: "File Upload", diff --git a/src/components/BuilderCore/shared/types.ts b/src/components/BuilderCore/shared/types.ts index cc2f4ab..ddd3eb6 100644 --- a/src/components/BuilderCore/shared/types.ts +++ b/src/components/BuilderCore/shared/types.ts @@ -13,7 +13,8 @@ export type FieldType = | "select" | "date" | "file" - | "page_break"; + | "page_break" + | "time"; export interface FieldOption { label: string; @@ -45,6 +46,8 @@ export interface FormFieldConfig { multiSelect?: boolean; minDate?: string; maxDate?: string; + minTime?: string; + maxTime?: string; logic?: FieldLogic; } diff --git a/src/components/common/DatePicker.tsx b/src/components/common/DatePicker.tsx index 44aef84..783dcd9 100644 --- a/src/components/common/DatePicker.tsx +++ b/src/components/common/DatePicker.tsx @@ -1,4 +1,4 @@ -import { useRef } from "react"; +import { useState, useRef, useEffect } from "react"; interface DatePickerProp { label: string; @@ -23,17 +23,116 @@ function DatePicker({ required = false, subtitle, }: DatePickerProp) { - const inputRef = useRef(null); - - const handleContainerClick = () => { - if (!disabled && inputRef.current) { - inputRef.current.showPicker?.(); - inputRef.current.focus(); + const [isOpen, setIsOpen] = useState(false); + const [currentMonth, setCurrentMonth] = useState(() => { + if (value) { + const [y, m, d] = value.split("-"); + if (y && m && d) return new Date(Number(y), Number(m) - 1, Number(d)); } + return new Date(); + }); + const containerRef = useRef(null); + + const selectedDate = value + ? (() => { + const [y, m, d] = value.split("-"); + return new Date(Number(y), Number(m) - 1, Number(d)); + })() + : null; + const minDate = min ? new Date(`${min}T00:00:00`) : null; + const maxDate = max ? new Date(`${max}T23:59:59`) : null; + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + containerRef.current && + !containerRef.current.contains(event.target as Node) + ) { + setIsOpen(false); + } + }; + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, []); + + const getDaysInMonth = (date: Date) => { + const year = date.getFullYear(); + const month = date.getMonth(); + const firstDay = new Date(year, month, 1); + const lastDay = new Date(year, month + 1, 0); + const daysInMonth = lastDay.getDate(); + const startingDayOfWeek = firstDay.getDay(); + return { daysInMonth, startingDayOfWeek }; + }; + + const isDateDisabled = (date: Date) => { + if (minDate && date < new Date(minDate.setHours(0, 0, 0, 0))) return true; + if (maxDate && date > new Date(maxDate.setHours(23, 59, 59, 999))) + return true; + return false; + }; + + const handleDateSelect = (day: number) => { + const newDate = new Date( + currentMonth.getFullYear(), + currentMonth.getMonth(), + day, + ); + if (isDateDisabled(newDate)) return; + + const year = newDate.getFullYear(); + const month = String(newDate.getMonth() + 1).padStart(2, "0"); + const dayStr = String(newDate.getDate()).padStart(2, "0"); + const formattedDate = `${year}-${month}-${dayStr}`; + + const syntheticEvent = { + target: { value: formattedDate, name }, + } as React.ChangeEvent; + onChange?.(syntheticEvent); + setIsOpen(false); + }; + + const goToPrevMonth = () => { + setCurrentMonth( + new Date(currentMonth.getFullYear(), currentMonth.getMonth() - 1, 1), + ); + }; + + const goToNextMonth = () => { + setCurrentMonth( + new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1, 1), + ); + }; + + const formatDisplayDate = (dateStr: string) => { + const [year, month, day] = dateStr.split("-").map(Number); + const date = new Date(year, month - 1, day); + return date.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }); }; + const { daysInMonth, startingDayOfWeek } = getDaysInMonth(currentMonth); + const monthNames = [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", + ]; + const dayNames = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"]; + return ( -
+
{label && (
); + case "time": + return ( +
+ handleFieldChange(field.id, e.target.value)} + min={field.minTime} + max={field.maxTime} + subtitle={field.subtitle} + /> +
+ ); case "file": return (
diff --git a/src/layouts/SinglePage.tsx b/src/layouts/SinglePage.tsx index f3af9af..cd770b2 100644 --- a/src/layouts/SinglePage.tsx +++ b/src/layouts/SinglePage.tsx @@ -4,6 +4,7 @@ import Checkbox from "../components/common/Checkbox"; import RadioButton from "../components/common/RadioButton"; import Button from "../components/common/Button"; import DatePicker from "../components/common/DatePicker"; +import TimePicker from "../components/common/TimePicker"; import FileUpload from "../components/common/FileUpload"; import Select from "../components/common/Select"; import TextArea from "../components/common/TextArea"; @@ -374,6 +375,28 @@ function SinglePage({ />
); + case "time": + return ( +
+ + handleFieldChange(field.id, e.target.value)} + min={field.minTime} + max={field.maxTime} + /> +
+ ); case "file": return (