diff --git a/date-picker/README.md b/date-picker/README.md new file mode 100644 index 0000000..40f421a --- /dev/null +++ b/date-picker/README.md @@ -0,0 +1,125 @@ +# DatePicker +A fully-featured date picker component with a calendar dropdown popup. + +## Getting Started + +Install dependencies: +```bash +npm install +``` + +Share the component to your Webflow workspace: +```bash +npx webflow library share +``` + +For local development: +```bash +npm run dev +``` + +## Designer Properties + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| ID | Id | — | HTML ID attribute for the date picker container | +| Mode | Variant | single | Date selection mode: single date or date range | +| Date Format | Variant | MM/DD/YYYY | Display format for selected date in input field | +| Size | Variant | md | Size variant for the input field and calendar | +| Label | Text | Select Date | Label text displayed above the input field | +| Placeholder | Text | Choose a date | Placeholder text shown in empty input field | +| Start Placeholder | Text | Start date | Placeholder for start date in range mode | +| End Placeholder | Text | End date | Placeholder for end date in range mode | +| Clear Button Text | Text | Clear date | Accessible label for the clear button | +| Today Button Text | Text | Today | Text for the today quick-select button | +| Previous Month Label | Text | Previous month | Accessible label for previous month button | +| Next Month Label | Text | Next month | Accessible label for next month button | +| Show Label | Visibility | — | Show or hide the label above the input | +| Show Clear Button | Boolean | true | Show clear button to reset selection | +| Show Today Button | Boolean | true | Show today button in calendar footer for quick selection | +| Show Week Numbers | Boolean | false | Display week numbers in the calendar grid | +| Highlight Today | Boolean | true | Visually highlight today's date in the calendar | +| Is Disabled | Boolean | false | Disable the entire date picker input | +| Is Required | Boolean | false | Mark the date picker as a required field | +| Close On Select | Boolean | true | Automatically close calendar after date selection | +| Min Date | Text | — | Minimum selectable date in YYYY-MM-DD format (dates before are disabled) | +| Max Date | Text | — | Maximum selectable date in YYYY-MM-DD format (dates after are disabled) | +| Default Date | Text | — | Default selected date in YYYY-MM-DD format | +| Default Start Date | Text | — | Default start date for range mode in YYYY-MM-DD format | +| Default End Date | Text | — | Default end date for range mode in YYYY-MM-DD format | +| Disabled Days Of Week | Text | — | Comma-separated day numbers to disable (0=Sunday, 6=Saturday) | +| First Day Of Week | Variant | sunday | First day of the week in calendar grid | +| Month Year Format | Variant | MMMM YYYY | Format for month/year display in calendar header | +| Helper Text | Text | — | Helper text displayed below the input field | +| Error Text | Text | — | Error message text displayed when validation fails | +| Show Helper Text | Visibility | — | Show or hide the helper text below input | +| Show Error Text | Visibility | — | Show or hide the error message | +| Name | Text | date | Form input name attribute for form submission | + +## Styling + +This component automatically adapts to your Webflow site's design system through site variables and inherited properties. + +### Site Variables + +To match your site's design system, define these CSS variables in your Webflow project settings. The component will use the fallback values shown below until you configure them. + +| Site Variable | What It Controls | Fallback | +|---------------|------------------|----------| +| --background-primary | Main background color for input and calendar | #ffffff | +| --background-secondary | Hover states for buttons and days | #f5f5f5 | +| --text-primary | Main text color for labels, input, and calendar | #1a1a1a | +| --text-secondary | Muted text for weekday labels and helper text | #737373 | +| --border-color | Input border, calendar border, and dividers | #e5e5e5 | +| --accent-color | Selected day background, today highlight, focus states | #1a1a1a | +| --accent-text-color | Text color on selected days | #ffffff | +| --border-radius | Corner rounding for input, calendar, and buttons | 8px | + +### Inherited Properties + +The component inherits these CSS properties from its parent element: +- `font-family` — Typography style +- `color` — Text color +- `line-height` — Text spacing + +## Extending in Code + +### Custom Date Validation + +Add custom validation logic by accessing the component's selected date value: + +```javascript +// Listen for date selection changes +const datePicker = document.querySelector('[data-component="date-picker"]'); +datePicker.addEventListener('change', (event) => { + const selectedDate = new Date(event.target.value); + const dayOfWeek = selectedDate.getDay(); + + // Example: Prevent weekend selections + if (dayOfWeek === 0 || dayOfWeek === 6) { + alert('Please select a weekday'); + event.target.value = ''; + } +}); +``` + +### Integration with Form Libraries + +The component works seamlessly with form validation libraries by using the `name` property: + +```javascript +// Example with a form submission handler +const form = document.querySelector('form'); +form.addEventListener('submit', (event) => { + event.preventDefault(); + const formData = new FormData(form); + const selectedDate = formData.get('date'); // Uses the 'name' prop value + + // Process the date value + console.log('Selected date:', selectedDate); +}); +``` + +## Dependencies + +- **react-day-picker** — Flexible date picker component library for React \ No newline at end of file diff --git a/date-picker/index.html b/date-picker/index.html new file mode 100644 index 0000000..1b449da --- /dev/null +++ b/date-picker/index.html @@ -0,0 +1,17 @@ + + + + + + DatePicker + + + +
+ + + diff --git a/date-picker/metadata.json b/date-picker/metadata.json new file mode 100644 index 0000000..e30c7e8 --- /dev/null +++ b/date-picker/metadata.json @@ -0,0 +1,5 @@ +{ + "name": "Date Picker", + "description": "Calendar-based date picker with range selection, min/max date constraints, and configurable date formats.", + "category": "Forms & Input" +} diff --git a/date-picker/package.json b/date-picker/package.json new file mode 100644 index 0000000..95900ae --- /dev/null +++ b/date-picker/package.json @@ -0,0 +1,26 @@ +{ + "name": "date-picker", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.1.1", + "react-dom": "^19.1.1", + "react-day-picker": "^9.0.0" + }, + "devDependencies": { + "@types/react": "^19.1.13", + "@types/react-dom": "^19.1.9", + "@vitejs/plugin-react": "^5.0.3", + "@webflow/data-types": "^1.0.1", + "@webflow/react": "^1.0.1", + "@webflow/webflow-cli": "^1.8.44", + "typescript": "~5.8.3", + "vite": "^7.1.7" + } +} \ No newline at end of file diff --git a/date-picker/screenshot-brand.png b/date-picker/screenshot-brand.png new file mode 100644 index 0000000..21ab831 Binary files /dev/null and b/date-picker/screenshot-brand.png differ diff --git a/date-picker/screenshot-dark.png b/date-picker/screenshot-dark.png new file mode 100644 index 0000000..b967e2b Binary files /dev/null and b/date-picker/screenshot-dark.png differ diff --git a/date-picker/screenshot-light.png b/date-picker/screenshot-light.png new file mode 100644 index 0000000..2b0dcb4 Binary files /dev/null and b/date-picker/screenshot-light.png differ diff --git a/date-picker/src/components/DatePicker/DatePicker.css b/date-picker/src/components/DatePicker/DatePicker.css new file mode 100644 index 0000000..a3701f4 --- /dev/null +++ b/date-picker/src/components/DatePicker/DatePicker.css @@ -0,0 +1,490 @@ +/* + * Webflow Site Variables Used: + * - --background-primary: Main background color for input and calendar + * - --background-secondary: Hover states for buttons and days + * - --text-primary: Main text color for labels, input, and calendar + * - --text-secondary: Muted text for weekday labels and helper text + * - --border-color: Input border, calendar border, and dividers + * - --accent-color: Selected day background, today highlight, focus states + * - --accent-text-color: Text color on selected days + * - --border-radius: Corner rounding for input, calendar, and buttons + */ + +/* Box sizing reset */ +.wf-datepicker *, +.wf-datepicker *::before, +.wf-datepicker *::after { + box-sizing: border-box; +} + +/* Root element - inherit Webflow typography + default padding */ +.wf-datepicker { + font-family: inherit; + color: inherit; + line-height: inherit; + padding: 24px; + position: relative; + --wf-datepicker-day-size: 36px; + --wf-datepicker-spacing: 4px; +} + +/* Size variants */ +.wf-datepicker-size-sm { + --wf-datepicker-day-size: 32px; + --wf-datepicker-spacing: 2px; + font-size: 14px; +} + +.wf-datepicker-size-md { + --wf-datepicker-day-size: 36px; + --wf-datepicker-spacing: 4px; + font-size: 16px; +} + +.wf-datepicker-size-lg { + --wf-datepicker-day-size: 40px; + --wf-datepicker-spacing: 6px; + font-size: 18px; +} + +/* Label */ +.wf-datepicker-label { + display: block; + margin-bottom: 8px; + font-weight: 500; + color: var(--text-primary, #1a1a1a); +} + +.wf-datepicker-required { + color: #dc2626; + margin-left: 4px; +} + +/* Input wrapper */ +.wf-datepicker-input-wrapper { + position: relative; + display: flex; + align-items: center; +} + +/* Input field */ +.wf-datepicker-input { + width: 100%; + padding: 12px 80px 12px 16px; + background: var(--background-primary, #ffffff); + border: 1px solid var(--border-color, #e5e5e5); + border-radius: var(--border-radius, 8px); + color: var(--text-primary, #1a1a1a); + font-size: inherit; + font-family: inherit; + line-height: 1.5; + cursor: pointer; + transition: border-color 0.2s, box-shadow 0.2s; +} + +.wf-datepicker-input::placeholder { + color: var(--text-secondary, #737373); +} + +.wf-datepicker-input:hover:not(:disabled) { + border-color: var(--accent-color, #1a1a1a); +} + +.wf-datepicker-input:focus { + outline: none; + border-color: var(--accent-color, #1a1a1a); + box-shadow: 0 0 0 3px rgba(26, 26, 26, 0.1); +} + +.wf-datepicker-input:disabled { + opacity: 0.5; + cursor: not-allowed; + background: var(--background-secondary, #f5f5f5); +} + +.wf-datepicker-size-sm .wf-datepicker-input { + padding: 8px 72px 8px 12px; + font-size: 14px; +} + +.wf-datepicker-size-lg .wf-datepicker-input { + padding: 16px 88px 16px 20px; + font-size: 18px; +} + +/* Input icons container */ +.wf-datepicker-input-icons { + position: absolute; + right: 8px; + display: flex; + align-items: center; + gap: 4px; +} + +/* Icon base styles */ +.wf-datepicker-icon { + width: 20px; + height: 20px; + color: var(--text-secondary, #737373); +} + +.wf-datepicker-size-sm .wf-datepicker-icon { + width: 16px; + height: 16px; +} + +.wf-datepicker-size-lg .wf-datepicker-icon { + width: 24px; + height: 24px; +} + +/* Clear button */ +.wf-datepicker-clear-button { + display: flex; + align-items: center; + justify-content: center; + padding: 6px; + background: transparent; + border: none; + border-radius: var(--border-radius, 8px); + cursor: pointer; + color: var(--text-secondary, #737373); + transition: background-color 0.2s, color 0.2s; +} + +.wf-datepicker-clear-button:hover { + background: var(--background-secondary, #f5f5f5); + color: var(--text-primary, #1a1a1a); +} + +.wf-datepicker-clear-button:focus-visible { + outline: 2px solid var(--accent-color, #1a1a1a); + outline-offset: 2px; +} + +/* Calendar button */ +.wf-datepicker-calendar-button { + display: flex; + align-items: center; + justify-content: center; + padding: 6px; + background: transparent; + border: none; + border-radius: var(--border-radius, 8px); + cursor: pointer; + color: var(--text-secondary, #737373); + transition: background-color 0.2s, color 0.2s; +} + +.wf-datepicker-calendar-button:hover:not(:disabled) { + background: var(--background-secondary, #f5f5f5); + color: var(--text-primary, #1a1a1a); +} + +.wf-datepicker-calendar-button:focus-visible { + outline: 2px solid var(--accent-color, #1a1a1a); + outline-offset: 2px; +} + +.wf-datepicker-calendar-button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Helper text */ +.wf-datepicker-helper-text { + margin-top: 6px; + font-size: 14px; + color: var(--text-secondary, #737373); +} + +/* Error text */ +.wf-datepicker-error-text { + margin-top: 6px; + font-size: 14px; + color: #dc2626; +} + +/* Dropdown calendar */ +.wf-datepicker-dropdown { + position: absolute; + top: calc(100% + 8px); + left: 0; + z-index: 1000; + padding: 16px; + background: var(--background-primary, #ffffff); + border: 1px solid var(--border-color, #e5e5e5); + border-radius: var(--border-radius, 8px); + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1); + min-width: 320px; +} + +.wf-datepicker-size-sm .wf-datepicker-dropdown { + padding: 12px; + min-width: 280px; +} + +.wf-datepicker-size-lg .wf-datepicker-dropdown { + padding: 20px; + min-width: 360px; +} + +/* Calendar header */ +.wf-datepicker-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; + gap: 12px; +} + +/* Month/year display */ +.wf-datepicker-month-year { + font-weight: 600; + color: var(--text-primary, #1a1a1a); + font-size: 16px; + text-align: center; + flex: 1; +} + +.wf-datepicker-size-sm .wf-datepicker-month-year { + font-size: 14px; +} + +.wf-datepicker-size-lg .wf-datepicker-month-year { + font-size: 18px; +} + +/* Navigation buttons */ +.wf-datepicker-nav-button { + display: flex; + align-items: center; + justify-content: center; + padding: 8px; + background: transparent; + border: none; + border-radius: var(--border-radius, 8px); + cursor: pointer; + color: var(--text-primary, #1a1a1a); + transition: background-color 0.2s; +} + +.wf-datepicker-nav-button:hover { + background: var(--background-secondary, #f5f5f5); +} + +.wf-datepicker-nav-button:focus-visible { + outline: 2px solid var(--accent-color, #1a1a1a); + outline-offset: 2px; +} + +.wf-datepicker-nav-button:disabled { + opacity: 0.3; + cursor: not-allowed; +} + +/* Calendar grid */ +.wf-datepicker-calendar { + margin-bottom: 12px; +} + +/* Weekdays header */ +.wf-datepicker-weekdays { + display: grid; + grid-template-columns: repeat(7, var(--wf-datepicker-day-size)); + gap: var(--wf-datepicker-spacing); + margin-bottom: 8px; +} + +.wf-datepicker-weekdays.wf-datepicker-with-week-numbers { + grid-template-columns: 32px repeat(7, var(--wf-datepicker-day-size)); +} + +/* Week number header */ +.wf-datepicker-week-number-header { + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + font-weight: 600; + color: var(--text-secondary, #737373); +} + +/* Weekday labels */ +.wf-datepicker-weekday { + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + font-weight: 600; + color: var(--text-secondary, #737373); + text-transform: uppercase; + height: 32px; +} + +.wf-datepicker-size-sm .wf-datepicker-weekday { + font-size: 11px; + height: 28px; +} + +.wf-datepicker-size-lg .wf-datepicker-weekday { + font-size: 13px; + height: 36px; +} + +/* Days container */ +.wf-datepicker-days { + display: flex; + flex-direction: column; + gap: var(--wf-datepicker-spacing); +} + +/* Week row */ +.wf-datepicker-week { + display: grid; + grid-template-columns: repeat(7, var(--wf-datepicker-day-size)); + gap: var(--wf-datepicker-spacing); +} + +/* Week number in row */ +.wf-datepicker-week-number { + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + font-weight: 500; + color: var(--text-secondary, #737373); + width: 32px; +} + +/* Day cell */ +.wf-datepicker-day { + width: var(--wf-datepicker-day-size); + height: var(--wf-datepicker-day-size); + display: flex; + align-items: center; + justify-content: center; + border: none; + background: transparent; + cursor: pointer; + border-radius: var(--border-radius, 8px); + color: var(--text-primary, #1a1a1a); + font-size: 14px; + font-weight: 400; + transition: background-color 0.2s, color 0.2s; + position: relative; +} + +.wf-datepicker-size-sm .wf-datepicker-day { + font-size: 13px; +} + +.wf-datepicker-size-lg .wf-datepicker-day { + font-size: 16px; +} + +.wf-datepicker-day:hover:not(.wf-datepicker-day-disabled):not(.wf-datepicker-day-empty) { + background: var(--background-secondary, #f5f5f5); +} + +.wf-datepicker-day:focus-visible { + outline: 2px solid var(--accent-color, #1a1a1a); + outline-offset: 2px; + z-index: 1; +} + +/* Empty day cells */ +.wf-datepicker-day-empty { + cursor: default; + pointer-events: none; +} + +/* Today */ +.wf-datepicker-day-today { + font-weight: 600; +} + +.wf-datepicker-day-today::after { + content: ''; + position: absolute; + bottom: 4px; + left: 50%; + transform: translateX(-50%); + width: 4px; + height: 4px; + border-radius: 50%; + background: var(--accent-color, #1a1a1a); +} + +/* Selected day */ +.wf-datepicker-day-selected { + background: var(--accent-color, #1a1a1a); + color: var(--accent-text-color, #ffffff); + font-weight: 600; +} + +.wf-datepicker-day-selected:hover { + background: var(--accent-color, #1a1a1a); + opacity: 0.9; +} + +.wf-datepicker-day-selected::after { + background: var(--accent-text-color, #ffffff); +} + +/* In range (for range mode) */ +.wf-datepicker-day-in-range { + background: rgba(26, 26, 26, 0.1); +} + +.wf-datepicker-day-in-range:hover { + background: rgba(26, 26, 26, 0.15); +} + +/* Disabled day */ +.wf-datepicker-day-disabled { + opacity: 0.3; + cursor: not-allowed; + pointer-events: none; +} + +/* Footer */ +.wf-datepicker-footer { + padding-top: 12px; + border-top: 1px solid var(--border-color, #e5e5e5); + display: flex; + justify-content: center; +} + +/* Today button */ +.wf-datepicker-today-button { + padding: 8px 16px; + background: transparent; + border: 1px solid var(--border-color, #e5e5e5); + border-radius: var(--border-radius, 8px); + cursor: pointer; + color: var(--text-primary, #1a1a1a); + font-size: 14px; + font-weight: 500; + transition: background-color 0.2s, border-color 0.2s; +} + +.wf-datepicker-today-button:hover { + background: var(--background-secondary, #f5f5f5); + border-color: var(--accent-color, #1a1a1a); +} + +.wf-datepicker-today-button:focus-visible { + outline: 2px solid var(--accent-color, #1a1a1a); + outline-offset: 2px; +} + +.wf-datepicker-size-sm .wf-datepicker-today-button { + padding: 6px 12px; + font-size: 13px; +} + +.wf-datepicker-size-lg .wf-datepicker-today-button { + padding: 10px 20px; + font-size: 16px; +} \ No newline at end of file diff --git a/date-picker/src/components/DatePicker/DatePicker.tsx b/date-picker/src/components/DatePicker/DatePicker.tsx new file mode 100644 index 0000000..29ed56c --- /dev/null +++ b/date-picker/src/components/DatePicker/DatePicker.tsx @@ -0,0 +1,600 @@ +import { useState, useRef, useEffect } from "react"; + +export interface DatePickerProps { + id?: string; + mode?: "single" | "range"; + dateFormat?: "MM/DD/YYYY" | "DD/MM/YYYY" | "YYYY-MM-DD"; + size?: "sm" | "md" | "lg"; + label?: string; + placeholder?: string; + startPlaceholder?: string; + endPlaceholder?: string; + clearButtonText?: string; + todayButtonText?: string; + previousMonthLabel?: string; + nextMonthLabel?: string; + showLabel?: boolean; + showClearButton?: boolean; + showTodayButton?: boolean; + showWeekNumbers?: boolean; + highlightToday?: boolean; + isDisabled?: boolean; + isRequired?: boolean; + closeOnSelect?: boolean; + minDate?: string; + maxDate?: string; + defaultDate?: string; + defaultStartDate?: string; + defaultEndDate?: string; + disabledDaysOfWeek?: string; + firstDayOfWeek?: "sunday" | "monday"; + monthYearFormat?: "MMMM YYYY" | "MMM YYYY" | "MM/YYYY"; + helperText?: string; + errorText?: string; + showHelperText?: boolean; + showErrorText?: boolean; + name?: string; +} + +const MONTH_NAMES = [ + "January", "February", "March", "April", "May", "June", + "July", "August", "September", "October", "November", "December" +]; + +const MONTH_NAMES_SHORT = [ + "Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" +]; + +const DAY_NAMES = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; +const DAY_NAMES_MONDAY_FIRST = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; + +export default function DatePicker({ + id, + mode = "single", + dateFormat = "MM/DD/YYYY", + size = "md", + label = "Select Date", + placeholder = "Choose a date", + startPlaceholder = "Start date", + endPlaceholder = "End date", + clearButtonText = "Clear date", + todayButtonText = "Today", + previousMonthLabel = "Previous month", + nextMonthLabel = "Next month", + showLabel = true, + showClearButton = true, + showTodayButton = true, + showWeekNumbers = false, + highlightToday = true, + isDisabled = false, + isRequired = false, + closeOnSelect = true, + minDate = "", + maxDate = "", + defaultDate = "", + defaultStartDate = "", + defaultEndDate = "", + disabledDaysOfWeek = "", + firstDayOfWeek = "sunday", + monthYearFormat = "MMMM YYYY", + helperText = "", + errorText = "", + showHelperText = true, + showErrorText = false, + name = "date", +}: DatePickerProps) { + const [isOpen, setIsOpen] = useState(false); + const [selectedDate, setSelectedDate] = useState( + defaultDate ? parseDate(defaultDate) : null + ); + const [rangeStart, setRangeStart] = useState( + defaultStartDate ? parseDate(defaultStartDate) : null + ); + const [rangeEnd, setRangeEnd] = useState( + defaultEndDate ? parseDate(defaultEndDate) : null + ); + const [currentMonth, setCurrentMonth] = useState(new Date().getMonth()); + const [currentYear, setCurrentYear] = useState(new Date().getFullYear()); + const [focusedDay, setFocusedDay] = useState(null); + + const containerRef = useRef(null); + const inputRef = useRef(null); + const calendarRef = useRef(null); + + const minDateObj = minDate ? parseDate(minDate) : null; + const maxDateObj = maxDate ? parseDate(maxDate) : null; + const disabledDays = disabledDaysOfWeek + ? disabledDaysOfWeek.split(",").map((d) => parseInt(d.trim(), 10)) + : []; + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + containerRef.current && + !containerRef.current.contains(event.target as Node) + ) { + setIsOpen(false); + } + }; + + if (isOpen) { + document.addEventListener("mousedown", handleClickOutside); + } + + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, [isOpen]); + + useEffect(() => { + if (isOpen && calendarRef.current && focusedDay !== null) { + const dayButton = calendarRef.current.querySelector( + `[data-day="${focusedDay}"]` + ) as HTMLButtonElement; + if (dayButton) { + dayButton.focus(); + } + } + }, [focusedDay, isOpen]); + + function parseDate(dateStr: string): Date | null { + if (!dateStr) return null; + const parts = dateStr.split("-"); + if (parts.length === 3) { + return new Date( + parseInt(parts[0], 10), + parseInt(parts[1], 10) - 1, + parseInt(parts[2], 10) + ); + } + return null; + } + + function formatDate(date: Date | null): string { + if (!date) return ""; + const day = date.getDate().toString().padStart(2, "0"); + const month = (date.getMonth() + 1).toString().padStart(2, "0"); + const year = date.getFullYear(); + + switch (dateFormat) { + case "DD/MM/YYYY": + return `${day}/${month}/${year}`; + case "YYYY-MM-DD": + return `${year}-${month}-${day}`; + case "MM/DD/YYYY": + default: + return `${month}/${day}/${year}`; + } + } + + function getDisplayValue(): string { + if (mode === "range") { + const start = rangeStart ? formatDate(rangeStart) : ""; + const end = rangeEnd ? formatDate(rangeEnd) : ""; + if (start && end) return `${start} - ${end}`; + if (start) return start; + return ""; + } + return selectedDate ? formatDate(selectedDate) : ""; + } + + function getDaysInMonth(month: number, year: number): number { + return new Date(year, month + 1, 0).getDate(); + } + + function getFirstDayOfMonth(month: number, year: number): number { + const day = new Date(year, month, 1).getDay(); + return firstDayOfWeek === "monday" ? (day === 0 ? 6 : day - 1) : day; + } + + function getWeekNumber(date: Date): number { + const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())); + const dayNum = d.getUTCDay() || 7; + d.setUTCDate(d.getUTCDate() + 4 - dayNum); + const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); + return Math.ceil((((d.getTime() - yearStart.getTime()) / 86400000) + 1) / 7); + } + + function isDateDisabled(date: Date): boolean { + if (minDateObj && date < minDateObj) return true; + if (maxDateObj && date > maxDateObj) return true; + if (disabledDays.includes(date.getDay())) return true; + return false; + } + + function isSameDay(date1: Date | null, date2: Date | null): boolean { + if (!date1 || !date2) return false; + return ( + date1.getDate() === date2.getDate() && + date1.getMonth() === date2.getMonth() && + date1.getFullYear() === date2.getFullYear() + ); + } + + function isInRange(date: Date): boolean { + if (mode !== "range" || !rangeStart || !rangeEnd) return false; + return date >= rangeStart && date <= rangeEnd; + } + + function handleDayClick(day: number) { + const clickedDate = new Date(currentYear, currentMonth, day); + if (isDateDisabled(clickedDate)) return; + + if (mode === "range") { + if (!rangeStart || (rangeStart && rangeEnd)) { + setRangeStart(clickedDate); + setRangeEnd(null); + } else { + if (clickedDate < rangeStart) { + setRangeEnd(rangeStart); + setRangeStart(clickedDate); + } else { + setRangeEnd(clickedDate); + } + if (closeOnSelect) { + setIsOpen(false); + } + } + } else { + setSelectedDate(clickedDate); + if (closeOnSelect) { + setIsOpen(false); + } + } + } + + function handleTodayClick() { + const today = new Date(); + setCurrentMonth(today.getMonth()); + setCurrentYear(today.getFullYear()); + if (mode === "range") { + setRangeStart(today); + setRangeEnd(null); + } else { + setSelectedDate(today); + if (closeOnSelect) { + setIsOpen(false); + } + } + } + + function handleClear() { + if (mode === "range") { + setRangeStart(null); + setRangeEnd(null); + } else { + setSelectedDate(null); + } + } + + function handlePreviousMonth() { + if (currentMonth === 0) { + setCurrentMonth(11); + setCurrentYear(currentYear - 1); + } else { + setCurrentMonth(currentMonth - 1); + } + } + + function handleNextMonth() { + if (currentMonth === 11) { + setCurrentMonth(0); + setCurrentYear(currentYear + 1); + } else { + setCurrentMonth(currentMonth + 1); + } + } + + function handleKeyDown(e: React.KeyboardEvent, day: number) { + const daysInMonth = getDaysInMonth(currentMonth, currentYear); + let newDay = day; + + switch (e.key) { + case "ArrowLeft": + e.preventDefault(); + newDay = day > 1 ? day - 1 : day; + break; + case "ArrowRight": + e.preventDefault(); + newDay = day < daysInMonth ? day + 1 : day; + break; + case "ArrowUp": + e.preventDefault(); + newDay = day > 7 ? day - 7 : day; + break; + case "ArrowDown": + e.preventDefault(); + newDay = day + 7 <= daysInMonth ? day + 7 : day; + break; + case "Enter": + case " ": + e.preventDefault(); + handleDayClick(day); + return; + case "Escape": + e.preventDefault(); + setIsOpen(false); + inputRef.current?.focus(); + return; + default: + return; + } + + setFocusedDay(newDay); + } + + function getMonthYearDisplay(): string { + const monthName = + monthYearFormat === "MMM YYYY" + ? MONTH_NAMES_SHORT[currentMonth] + : MONTH_NAMES[currentMonth]; + const yearStr = currentYear.toString(); + + switch (monthYearFormat) { + case "MM/YYYY": + return `${(currentMonth + 1).toString().padStart(2, "0")}/${yearStr}`; + case "MMM YYYY": + return `${monthName} ${yearStr}`; + case "MMMM YYYY": + default: + return `${monthName} ${yearStr}`; + } + } + + function renderCalendar() { + const daysInMonth = getDaysInMonth(currentMonth, currentYear); + const firstDay = getFirstDayOfMonth(currentMonth, currentYear); + const today = new Date(); + const days: JSX.Element[] = []; + const weeks: JSX.Element[][] = []; + let currentWeek: JSX.Element[] = []; + + for (let i = 0; i < firstDay; i++) { + currentWeek.push( +
+ ); + } + + for (let day = 1; day <= daysInMonth; day++) { + const date = new Date(currentYear, currentMonth, day); + const isToday = highlightToday && isSameDay(date, today); + const isSelected = + mode === "range" + ? isSameDay(date, rangeStart) || isSameDay(date, rangeEnd) + : isSameDay(date, selectedDate); + const inRange = mode === "range" && isInRange(date); + const disabled = isDateDisabled(date); + + const dayClasses = [ + "wf-datepicker-day", + isToday && "wf-datepicker-day-today", + isSelected && "wf-datepicker-day-selected", + inRange && "wf-datepicker-day-in-range", + disabled && "wf-datepicker-day-disabled", + ] + .filter(Boolean) + .join(" "); + + currentWeek.push( + + ); + + if ((firstDay + day) % 7 === 0 || day === daysInMonth) { + while (currentWeek.length < 7) { + currentWeek.push( +
+ ); + } + weeks.push([...currentWeek]); + currentWeek = []; + } + } + + return weeks.map((week, weekIndex) => { + const firstDayOfWeek = new Date(currentYear, currentMonth, weekIndex * 7 - firstDay + 1); + const weekNumber = getWeekNumber(firstDayOfWeek); + + return ( +
+ {showWeekNumbers && ( +
{weekNumber}
+ )} + {week} +
+ ); + }); + } + + const dayNames = firstDayOfWeek === "monday" ? DAY_NAMES_MONDAY_FIRST : DAY_NAMES; + const sizeClass = `wf-datepicker-size-${size}`; + const displayValue = getDisplayValue(); + const showClear = showClearButton && displayValue && !isDisabled; + + return ( +
+ {showLabel && ( + + )} + +
+ !isDisabled && setIsOpen(!isOpen)} + onFocus={() => !isDisabled && setIsOpen(true)} + readOnly + disabled={isDisabled} + required={isRequired} + aria-expanded={isOpen} + aria-haspopup="dialog" + /> + +
+ {showClear && ( + + )} + + +
+
+ + {showHelperText && helperText && ( +
{helperText}
+ )} + + {showErrorText && errorText && ( +
{errorText}
+ )} + + {isOpen && ( +
+
+ + +
{getMonthYearDisplay()}
+ + +
+ +
+
+ {showWeekNumbers &&
Wk
} + {dayNames.map((day) => ( +
+ {day} +
+ ))} +
+ +
{renderCalendar()}
+
+ + {showTodayButton && ( +
+ +
+ )} +
+ )} +
+ ); +} \ No newline at end of file diff --git a/date-picker/src/components/DatePicker/DatePicker.webflow.tsx b/date-picker/src/components/DatePicker/DatePicker.webflow.tsx new file mode 100644 index 0000000..678e0a3 --- /dev/null +++ b/date-picker/src/components/DatePicker/DatePicker.webflow.tsx @@ -0,0 +1,215 @@ +import DatePicker from "./DatePicker"; +import { props } from "@webflow/data-types"; +import { declareComponent } from "@webflow/react"; +import "./DatePicker.css"; + +export default declareComponent(DatePicker, { + name: "DatePicker", + description: "A fully-featured date picker component with a calendar dropdown popup. Clicking the input field opens a calendar showing the current month in a grid layout with day names. Users can navigate between months using previous/next arrow buttons and select dates by clicking. The selected date displays in the input field in a configurable format. Supports single date selection or date range mode where users select start and end dates with visual highlighting of the range. Today's date is highlighted with a distinct visual style. Dates outside configurable min/max boundaries are disabled and grayed out. The input includes a calendar icon and a clear button to reset the selection. Fully keyboard navigable for accessibility with arrow keys for date navigation and Enter to select.", + group: "Forms", + options: { + ssr: false, + applyTagSelectors: true + }, + props: { + id: props.Id({ + name: "Element ID", + group: "Settings", + tooltip: "HTML ID attribute for the date picker container" + }), + mode: props.Variant({ + name: "Selection Mode", + options: ["single", "range"], + defaultValue: "single", + group: "Behavior", + tooltip: "Date selection mode: single date or date range" + }), + dateFormat: props.Variant({ + name: "Date Format", + options: ["MM/DD/YYYY", "DD/MM/YYYY", "YYYY-MM-DD"], + defaultValue: "MM/DD/YYYY", + group: "Style", + tooltip: "Display format for selected date in input field" + }), + size: props.Variant({ + name: "Size", + options: ["sm", "md", "lg"], + defaultValue: "md", + group: "Style", + tooltip: "Size variant for the input field and calendar" + }), + label: props.Text({ + name: "Label", + defaultValue: "Select Date", + group: "Content", + tooltip: "Label text displayed above the input field" + }), + placeholder: props.Text({ + name: "Placeholder", + defaultValue: "Choose a date", + group: "Content", + tooltip: "Placeholder text shown in empty input field" + }), + startPlaceholder: props.Text({ + name: "Start Placeholder", + defaultValue: "Start date", + group: "Content", + tooltip: "Placeholder for start date in range mode" + }), + endPlaceholder: props.Text({ + name: "End Placeholder", + defaultValue: "End date", + group: "Content", + tooltip: "Placeholder for end date in range mode" + }), + clearButtonText: props.Text({ + name: "Clear Button Text", + defaultValue: "Clear date", + group: "Content", + tooltip: "Accessible label for the clear button" + }), + todayButtonText: props.Text({ + name: "Today Button Text", + defaultValue: "Today", + group: "Content", + tooltip: "Text for the today quick-select button" + }), + previousMonthLabel: props.Text({ + name: "Previous Month Label", + defaultValue: "Previous month", + group: "Content", + tooltip: "Accessible label for previous month button" + }), + nextMonthLabel: props.Text({ + name: "Next Month Label", + defaultValue: "Next month", + group: "Content", + tooltip: "Accessible label for next month button" + }), + showLabel: props.Visibility({ + name: "Show Label", + group: "Display", + tooltip: "Show or hide the label above the input" + }), + showClearButton: props.Boolean({ + name: "Show Clear Button", + defaultValue: true, + group: "Display", + tooltip: "Show clear button to reset selection" + }), + showTodayButton: props.Boolean({ + name: "Show Today Button", + defaultValue: true, + group: "Display", + tooltip: "Show today button in calendar footer for quick selection" + }), + showWeekNumbers: props.Boolean({ + name: "Show Week Numbers", + defaultValue: false, + group: "Display", + tooltip: "Display week numbers in the calendar grid" + }), + highlightToday: props.Boolean({ + name: "Highlight Today", + defaultValue: true, + group: "Display", + tooltip: "Visually highlight today's date in the calendar" + }), + isDisabled: props.Boolean({ + name: "Disabled", + defaultValue: false, + group: "Behavior", + tooltip: "Disable the entire date picker input" + }), + isRequired: props.Boolean({ + name: "Required", + defaultValue: false, + group: "Behavior", + tooltip: "Mark the date picker as a required field" + }), + closeOnSelect: props.Boolean({ + name: "Close On Select", + defaultValue: true, + group: "Behavior", + tooltip: "Automatically close calendar after date selection" + }), + minDate: props.Text({ + name: "Minimum Date", + defaultValue: "", + group: "Date Constraints", + tooltip: "Minimum selectable date in YYYY-MM-DD format (dates before are disabled)" + }), + maxDate: props.Text({ + name: "Maximum Date", + defaultValue: "", + group: "Date Constraints", + tooltip: "Maximum selectable date in YYYY-MM-DD format (dates after are disabled)" + }), + defaultDate: props.Text({ + name: "Default Date", + defaultValue: "", + group: "Date Constraints", + tooltip: "Default selected date in YYYY-MM-DD format" + }), + defaultStartDate: props.Text({ + name: "Default Start Date", + defaultValue: "", + group: "Date Constraints", + tooltip: "Default start date for range mode in YYYY-MM-DD format" + }), + defaultEndDate: props.Text({ + name: "Default End Date", + defaultValue: "", + group: "Date Constraints", + tooltip: "Default end date for range mode in YYYY-MM-DD format" + }), + disabledDaysOfWeek: props.Text({ + name: "Disabled Days of Week", + defaultValue: "", + group: "Date Constraints", + tooltip: "Comma-separated day numbers to disable (0=Sunday, 6=Saturday)" + }), + firstDayOfWeek: props.Variant({ + name: "First Day of Week", + options: ["sunday", "monday"], + defaultValue: "sunday", + group: "Style", + tooltip: "First day of the week in calendar grid" + }), + monthYearFormat: props.Variant({ + name: "Month Year Format", + options: ["MMMM YYYY", "MMM YYYY", "MM/YYYY"], + defaultValue: "MMMM YYYY", + group: "Style", + tooltip: "Format for month/year display in calendar header" + }), + helperText: props.Text({ + name: "Helper Text", + defaultValue: "", + group: "Content", + tooltip: "Helper text displayed below the input field" + }), + errorText: props.Text({ + name: "Error Text", + defaultValue: "", + group: "Content", + tooltip: "Error message text displayed when validation fails" + }), + showHelperText: props.Visibility({ + name: "Show Helper Text", + group: "Display", + tooltip: "Show or hide the helper text below input" + }), + showErrorText: props.Visibility({ + name: "Show Error Text", + group: "Display", + tooltip: "Show or hide the error message" + }), + name: props.Text({ + name: "Input Name", + defaultValue: "date", + group: "Settings", + tooltip: "Form input name attribute for form submission" + }) + } +}); \ No newline at end of file diff --git a/date-picker/src/components/DatePicker/DatePickerSimple.webflow.tsx b/date-picker/src/components/DatePicker/DatePickerSimple.webflow.tsx new file mode 100644 index 0000000..89fb860 --- /dev/null +++ b/date-picker/src/components/DatePicker/DatePickerSimple.webflow.tsx @@ -0,0 +1,96 @@ +import DatePicker from "./DatePicker"; +import { props } from "@webflow/data-types"; +import { declareComponent } from "@webflow/react"; +import "./DatePicker.css"; + +export default declareComponent(DatePicker, { + name: "DatePicker (Simple)", + description: "A fully-featured date picker component with a calendar dropdown popup. Clicking the input field opens a calendar showing the current month in a grid layout with day names. Users can navigate between months using previous/next arrow buttons and select dates by clicking. The selected date displays in the input field in a configurable format. Supports single date selection or date range mode where users select start and end dates with visual highlighting of the range. Today's date is highlighted with a distinct visual style. Dates outside configurable min/max boundaries are disabled and grayed out. The input includes a calendar icon and a clear button to reset the selection. Fully keyboard navigable for accessibility with arrow keys for date navigation and Enter to select.", + group: "Forms", + options: { + ssr: false, + applyTagSelectors: true + }, + props: { + id: props.Id({ + name: "Element ID", + group: "Settings", + tooltip: "HTML ID attribute for the date picker container" + }), + label: props.Text({ + name: "Label", + defaultValue: "Select Date", + group: "Content", + tooltip: "Label text displayed above the input field" + }), + placeholder: props.Text({ + name: "Placeholder", + defaultValue: "Choose a date", + group: "Content", + tooltip: "Placeholder text shown in empty input field" + }), + showLabel: props.Visibility({ + name: "Show Label", + group: "Display", + tooltip: "Show or hide the label above the input" + }), + isDisabled: props.Boolean({ + name: "Disabled", + defaultValue: false, + group: "Behavior", + tooltip: "Disable the entire date picker input" + }), + isRequired: props.Boolean({ + name: "Required", + defaultValue: false, + group: "Behavior", + tooltip: "Mark the date picker as a required field" + }), + minDate: props.Text({ + name: "Minimum Date", + defaultValue: "", + group: "Date Constraints", + tooltip: "Minimum selectable date in YYYY-MM-DD format (dates before are disabled)" + }), + maxDate: props.Text({ + name: "Maximum Date", + defaultValue: "", + group: "Date Constraints", + tooltip: "Maximum selectable date in YYYY-MM-DD format (dates after are disabled)" + }), + defaultDate: props.Text({ + name: "Default Date", + defaultValue: "", + group: "Date Constraints", + tooltip: "Default selected date in YYYY-MM-DD format" + }), + helperText: props.Text({ + name: "Helper Text", + defaultValue: "", + group: "Content", + tooltip: "Helper text displayed below the input field" + }), + errorText: props.Text({ + name: "Error Text", + defaultValue: "", + group: "Content", + tooltip: "Error message text displayed when validation fails" + }), + showHelperText: props.Visibility({ + name: "Show Helper Text", + group: "Display", + tooltip: "Show or hide the helper text below input" + }), + showErrorText: props.Visibility({ + name: "Show Error Text", + group: "Display", + tooltip: "Show or hide the error message" + }), + name: props.Text({ + name: "Input Name", + defaultValue: "date", + group: "Settings", + tooltip: "Form input name attribute for form submission" + }) + } +}); \ No newline at end of file diff --git a/date-picker/src/main.tsx b/date-picker/src/main.tsx new file mode 100644 index 0000000..6d5074e --- /dev/null +++ b/date-picker/src/main.tsx @@ -0,0 +1,370 @@ +import { StrictMode, useState } from "react" +import { createRoot } from "react-dom/client" +import DatePicker from "./components/DatePicker/DatePicker" +import "./components/DatePicker/DatePicker.css" + +type ThemeVars = { + "--background-primary": string + "--background-secondary": string + "--text-primary": string + "--text-secondary": string + "--border-color": string + "--accent-color": string + "--accent-text-color": string + "--border-radius": string +} + +const themes: Record = { + light: { + "--background-primary": "#ffffff", + "--background-secondary": "#f5f5f5", + "--text-primary": "#1a1a1a", + "--text-secondary": "#737373", + "--border-color": "#e5e5e5", + "--accent-color": "#2563eb", + "--accent-text-color": "#ffffff", + "--border-radius": "8px", + }, + dark: { + "--background-primary": "#0a0a0a", + "--background-secondary": "#1a1a1a", + "--text-primary": "#fafafa", + "--text-secondary": "#a3a3a3", + "--border-color": "#2a2a2a", + "--accent-color": "#3b82f6", + "--accent-text-color": "#ffffff", + "--border-radius": "8px", + }, + brand: { + "--background-primary": "#fef7f0", + "--background-secondary": "#fde8d0", + "--text-primary": "#1c1917", + "--text-secondary": "#78716c", + "--border-color": "#e7e5e4", + "--accent-color": "#ea580c", + "--accent-text-color": "#ffffff", + "--border-radius": "12px", + }, +} + +function App() { + const [activeTheme, setActiveTheme] = useState<"light" | "dark" | "brand" | "custom">("light") + const [customVars, setCustomVars] = useState(themes.light) + + const currentVars = activeTheme === "custom" ? customVars : themes[activeTheme] + + const handleThemeChange = (theme: "light" | "dark" | "brand" | "custom") => { + setActiveTheme(theme) + if (theme !== "custom") { + setCustomVars(themes[theme]) + } + } + + const handleCustomVarChange = (key: keyof ThemeVars, value: string) => { + setCustomVars((prev) => ({ ...prev, [key]: value })) + } + + const pageBackground = activeTheme === "dark" ? "#000000" : activeTheme === "brand" ? "#fef2e8" : "#f9fafb" + + return ( +
+
+
+

+ Theme Preview +

+ +
+ {(["light", "dark", "brand", "custom"] as const).map((theme) => ( + + ))} +
+ + {activeTheme === "custom" && ( +
+ {(Object.keys(customVars) as Array).map((key) => ( +
+ + handleCustomVarChange(key, e.target.value)} + style={{ + width: "100%", + height: key === "--border-radius" ? "32px" : "40px", + border: `1px solid ${currentVars["--border-color"]}`, + borderRadius: "4px", + padding: key === "--border-radius" ? "0 8px" : "0", + cursor: "pointer" + }} + /> +
+ ))} +
+ )} +
+ +
+

+ DatePicker Component Preview +

+ +
+

+ Default Configuration +

+ +
+ +
+

+ Date Range Mode +

+ +
+ +
+

+ With Date Constraints +

+ +
+ +
+

+ Compact Size with Error State +

+ +
+
+
+
+ ) +} + +createRoot(document.getElementById("root")!).render( + + + +) \ No newline at end of file diff --git a/date-picker/src/vite-env.d.ts b/date-picker/src/vite-env.d.ts new file mode 100644 index 0000000..151aa68 --- /dev/null +++ b/date-picker/src/vite-env.d.ts @@ -0,0 +1 @@ +/// \ No newline at end of file diff --git a/date-picker/tsconfig.app.json b/date-picker/tsconfig.app.json new file mode 100644 index 0000000..d775f2a --- /dev/null +++ b/date-picker/tsconfig.app.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsBuildInfo", + "target": "ES2022", + "useDefineForClassFields": true, + "lib": [ + "ES2022", + "DOM", + "DOM.Iterable" + ], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": [ + "src" + ] +} \ No newline at end of file diff --git a/date-picker/tsconfig.json b/date-picker/tsconfig.json new file mode 100644 index 0000000..65f670c --- /dev/null +++ b/date-picker/tsconfig.json @@ -0,0 +1,11 @@ +{ + "files": [], + "references": [ + { + "path": "./tsconfig.app.json" + }, + { + "path": "./tsconfig.node.json" + } + ] +} \ No newline at end of file diff --git a/date-picker/tsconfig.node.json b/date-picker/tsconfig.node.json new file mode 100644 index 0000000..c4a9a48 --- /dev/null +++ b/date-picker/tsconfig.node.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsBuildInfo", + "target": "ES2023", + "lib": [ + "ES2023" + ], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": [ + "vite.config.ts" + ] +} \ No newline at end of file diff --git a/date-picker/vite.config.ts b/date-picker/vite.config.ts new file mode 100644 index 0000000..c7a4f78 --- /dev/null +++ b/date-picker/vite.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], +}); \ No newline at end of file diff --git a/date-picker/webflow.json b/date-picker/webflow.json new file mode 100644 index 0000000..e17ee1a --- /dev/null +++ b/date-picker/webflow.json @@ -0,0 +1,10 @@ +{ + "library": { + "name": "DatePicker", + "components": [ + "./src/**/*.webflow.@(js|jsx|mjs|ts|tsx)" + ], + "description": "A fully-featured date picker component with a calendar dropdown popup. Clicking the input field opens a calendar showing the current month in a grid layout with day names. Users can navigate between months using previous/next arrow buttons and select dates by clicking. The selected date displays in the input field in a configurable format. Supports single date selection or date range mode where users select start and end dates with visual highlighting of the range. Today's date is highlighted with a distinct visual style. Dates outside configurable min/max boundaries are disabled and grayed out. The input includes a calendar icon and a clear button to reset the selection. Fully keyboard navigable for accessibility with arrow keys for date navigation and Enter to select.", + "id": "date-picker" + } +} \ No newline at end of file