diff --git a/README.md b/README.md index 5130c7e..eccc540 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,10 @@ Datenel is a web UI component for selecting dates. It provides a customizable date picker panel that can be easily integrated into your React applications. +![A preview of Datenel components for SingleWeekPicker and SingleDayPicker](https://s2.loli.net/2025/02/21/CTnxMcEOg53WK9y.png) + +*Shadow border not included. Battery included.* + *A Vue.js-supported version is in development.* ## Features @@ -57,7 +61,7 @@ export default () => { ## Supported Components & Props - [x] SingleDatePicker -- [ ] SingleWeekPicker +- [x] SingleWeekPicker - [ ] MultipleDatePicker - [ ] DateRangePicker diff --git a/package-lock.json b/package-lock.json index 59f5adf..6411d44 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,8 +17,6 @@ "eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-react-refresh": "^0.4.18", "globals": "^15.14.0", - "react": "^19.0.0", - "react-dom": "^19.0.0", "sass-embedded": "^1.85.0", "typescript": "~5.7.2", "typescript-eslint": "^8.22.0", @@ -1018,9 +1016,9 @@ } }, "node_modules/@humanwhocodes/retry": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.1.tgz", - "integrity": "sha512-c7hNEllBlenFTHBky65mhq8WD2kbN9Q6gk0bTk8lSBvc554jpXSkST1iePudpt7+A/AQvuHs9EMqjHDXMY1lrA==", + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.2.tgz", + "integrity": "sha512-xeO57FpIu4p1Ri3Jq/EXq4ClRm86dVF2z/+kvFnyqVYRavTZmaFaUBbWCOuuTh0o/g7DSsk6kc2vrS4Vl5oPOQ==", "dev": true, "license": "Apache-2.0", "engines": { @@ -3530,9 +3528,9 @@ } }, "node_modules/postcss": { - "version": "8.5.2", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.2.tgz", - "integrity": "sha512-MjOadfU3Ys9KYoX0AdkBlFEF1Vx37uCCeN4ZHnmwm9FfpbsGWMZeBLMmmpY+6Ocqod7mkdZ0DT31OlbsFrLlkA==", + "version": "8.5.3", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", + "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==", "dev": true, "funding": [ { @@ -3603,8 +3601,8 @@ "version": "19.0.0", "resolved": "https://registry.npmjs.org/react/-/react-19.0.0.tgz", "integrity": "sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==", - "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -3613,8 +3611,8 @@ "version": "19.0.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.0.0.tgz", "integrity": "sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ==", - "dev": true, "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.25.0" }, @@ -4162,8 +4160,8 @@ "version": "0.25.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz", "integrity": "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==", - "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/semver": { "version": "6.3.1", @@ -4453,14 +4451,14 @@ "license": "MIT" }, "node_modules/vite": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.1.0.tgz", - "integrity": "sha512-RjjMipCKVoR4hVfPY6GQTgveinjNuyLw+qruksLDvA5ktI1150VmcMBKmQaEWJhg/j6Uaf6dNCNA0AfdzUb/hQ==", + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.1.1.tgz", + "integrity": "sha512-4GgM54XrwRfrOp297aIYspIti66k56v16ZnqHvrIM7mG+HjDlAwS7p+Srr7J6fGvEdOJ5JcQ/D9T7HhtdXDTzA==", "dev": true, "license": "MIT", "dependencies": { "esbuild": "^0.24.2", - "postcss": "^8.5.1", + "postcss": "^8.5.2", "rollup": "^4.30.1" }, "bin": { diff --git a/package.json b/package.json index 0e7efb6..784ddb3 100644 --- a/package.json +++ b/package.json @@ -8,10 +8,6 @@ "preview": "vite preview", "prepublishOnly": "npm run build" }, - "peerDependencies": { - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - }, "devDependencies": { "@eslint/js": "^9.19.0", "@types/node": "^22.13.4", @@ -22,8 +18,6 @@ "eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-react-refresh": "^0.4.18", "globals": "^15.14.0", - "react": "^19.0.0", - "react-dom": "^19.0.0", "sass-embedded": "^1.85.0", "typescript": "~5.7.2", "typescript-eslint": "^8.22.0", @@ -31,13 +25,17 @@ "vite-plugin-css-injected-by-js": "^3.5.2", "vite-plugin-dts": "^4.5.0" }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, "exports": { "types": "./dist/src/index.d.ts", "import": "./dist/index.es.js", - "require": "./dist/index.cjs.js", + "require": "./dist/index.umd.js", "default": "./dist/index.es.js" }, - "main": "dist/index.cjs.js", + "main": "dist/index.umd.js", "module": "dist/index.es.js", "types": "dist/index.d.ts", "files": [ diff --git a/playground/app.scss b/playground/app.scss index f5c3583..ba7f0e0 100644 --- a/playground/app.scss +++ b/playground/app.scss @@ -10,8 +10,12 @@ body { justify-content: center; align-items: center; .border { - background: #fff; - border-radius: 5px; - box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); + display: flex; + gap: 0.5rem; + > * { + background: #fff; + border-radius: 5px; + box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); + } } } \ No newline at end of file diff --git a/playground/app.tsx b/playground/app.tsx index 670b7cc..77c036a 100644 --- a/playground/app.tsx +++ b/playground/app.tsx @@ -1,26 +1,12 @@ import React from 'react' -import { SingleDatePicker } from "../src/index" +import { SingleDatePicker, SingleWeekPicker } from "../src" import './app.scss' export default () => { - function onSelect(value) { - alert(`You select ${value.year}-${value.month}-${value.day}`) - } - return (
-
- alert('close')} - /> + console.log(date)} />
) } \ No newline at end of file diff --git a/src/components/SingleDatePicker.tsx b/src/components/SingleDatePicker.tsx index ef1582e..0d24d4e 100644 --- a/src/components/SingleDatePicker.tsx +++ b/src/components/SingleDatePicker.tsx @@ -3,20 +3,19 @@ import { getCalendarDates, getL10Weekday, generateUniqueId, applyColor } from '. export interface SingleDatePickerProps { /** - * Control the selected + * Control the selected * date programmatically, including situations like provide a default value or control the selected * date by parent component. Use 1-12 for month, instead of 0-11, if you are using object to set the * value. * @example { year: 2025, month: 1, day: 1 } * @example new Date(2025, 0, 1) * @default new Date() - */ + */ value?: Date | { year: number, month: number, day: number } /** * A callback function that will be called when a date is selected inside the panel. - * @param date - The date user selected. - * @returns {{ year: number, month: number, day: number }} - The date user selected. + * @param {{ year: number, month: number, day: number }} - The date user selected. * @example { year: 2025, month: 1, day: 1 } // User selected 1 Jan 2025 */ onSelect?: (date: { @@ -74,6 +73,7 @@ export interface SingleDatePickerProps { } /** + * SingleDatePicker * A panel that allows users to select a date. * * @component @@ -97,11 +97,11 @@ const SingleDatePicker: React.FC = ({ value, onSelect, lo if (!value) return if (!(value instanceof Date)) { if (value.year < 100) value.year = Number(`20${value.year}`) - if (value.month < 0 || value.month > 11) + if (value.month < 0 || value.month > 11) return console.warn('Invalid value: Month should be between 1 and 12.') if (value.day < 1 || value.day > 31) return console.warn('Invalid value: Day should be between 1 and 31.') - } + } const date = value instanceof Date ? value : new Date(value.year, value.month - 1, value.day) setSelectedDate(date) setCurrentMonth(date.getMonth()) @@ -121,17 +121,7 @@ const SingleDatePicker: React.FC = ({ value, onSelect, lo hoverColor: hoverColor, borderColor: borderColor }) - } , [mainColor, accentColor, reversedColor, hoverColor, borderColor]) - - useEffect(() => { - applyColor(uniqueId, { - mainColor: mainColor, - accentColor: accentColor, - reversedColor: reversedColor, - hoverColor: hoverColor, - borderColor: borderColor - }) - }, []) + }, [mainColor, accentColor, reversedColor, hoverColor, borderColor]) function selectDate(date: Date) { setSelectedDate(date) @@ -185,15 +175,17 @@ const SingleDatePicker: React.FC = ({ value, onSelect, lo setCurrentYear(currentYear + 1) }} aria-label={`Go to next year, ${currentYear + 1}, you are now at year ${currentYear}`}> -
- {Array.from({ length: 12 }).map((_, index) => )} +
+
+ {Array.from({ length: 12 }).map((_, index) => )} +
- { !!onClose && } + {!!onClose && }
) else return ( @@ -205,23 +197,25 @@ const SingleDatePicker: React.FC = ({ value, onSelect, lo -
- {l10nDays.map((day, index) =>
{day}
)} +
+
+ {l10nDays.map((day, index) =>
{day}
)} - {dates.map(date => )} + {dates.map(date => )} +
- { !!onClose && } + {!!onClose && }
) } diff --git a/src/components/SingleWeekPicker.tsx b/src/components/SingleWeekPicker.tsx new file mode 100644 index 0000000..95fc141 --- /dev/null +++ b/src/components/SingleWeekPicker.tsx @@ -0,0 +1,235 @@ +import { useEffect, useState } from "react" +import { generateUniqueId, applyColor, getL10Weekday, getCalendarDates, calculateWeekNum } from "../utils" + +export interface SingleWeekPickerProps { + /** + * The language code that will be used to localize the panel. + * Accept standard ISO 639-1 language code, such as 'zh-CN', 'en-US', 'ja-JP', etc. Note + * that it will not effect to the screen reader, but the screen reader will still read the + * date in the user’s language. + * @default navigator.language + */ + localization?: string + + /** + * The main color of the panel, including the text color and the border color. + *@default '#000000' + */ + mainColor?: string + + /** + * The accent color of the panel, including the background color of the selected date. + *@default '#000000' + */ + accentColor?: string + + /** + * The reversed color of the panel, including the text color of the selected date. + *@default '#ffffff' + */ + reversedColor?: string + + /** + * The hover color of the panel, including the hover background color of the date. + *@default '#00000017' + */ + hoverColor?: string + + /** + * The border color of the panel, including the divider color between the header and the body. + *@default '#e0e0e0' + */ + borderColor?: string + + /** + * A callback function that will be called when a week is selected inside the panel. Note that + * Datenel will follow the ISO 8601 standard to calculate the week number, which means that the first + * week of the year is the week with the first Friday in it (week started from Monday). + * @param {{ year: number, month: number, day: number }} - The date user selected. + * @example { year: 2025, month: 1, day: 1 } // User selected 1 Jan 2025 + */ + onSelect?: (date: { + weekYear: number, + weekNum: number + }) => void + + /** + * User requires to close the panel without select a specific date. Note that the close button is not + * visible, but can be read by screen reader. The close button for the screen reader is only available + * when this prop is not `undefined`. + * @default undefined + */ + onClose?: () => void + + /** + * Control the selected + * date programmatically, including situations like provide a default value or control the selected + * date by parent component. When using the Date object, the week number related to the date will be + * applied to the panel. + * @example { weekYear: 2025, weekNum: 1 } + * @example new Date(2025, 0, 1) + * @default new Date() + */ + value?: { weekYear: number, weekNum: number } | Date +} + +/** + * SingleWeekPicker + * A panel that allows users to select a week. + * + * @component + * + * @param + */ +export default ({ localization, mainColor = '#000000', accentColor = '#000000', reversedColor = '#ffffff', hoverColor = '#00000017', borderColor = '#e0e0e0', onClose, onSelect, value }: SingleWeekPickerProps) => { + const [currentMonth, setCurrentMonth] = useState(new Date().getMonth()) + const [currentYear, setCurrentYear] = useState(new Date().getFullYear()) + const [selectedWeek, setSelectedWeek] = useState<{ weekYear: number, weekNum: number }>(calculateWeekNum(new Date())) + const [selectMonth, setSelectMonth] = useState(false) + const [calendarWeeks, setCalendarWeeks] = useState([]) + const [l10nDays, setL10nDays] = useState([]) + const uniqueId = generateUniqueId() + + function skipToLastMonth() { + if (currentMonth === 0) { + setCurrentMonth(11) + setCurrentYear(currentYear - 1) + } + else setCurrentMonth(currentMonth - 1) + } + + function skipToNextMonth() { + if (currentMonth === 11) { + setCurrentMonth(0) + setCurrentYear(currentYear + 1) + } + else setCurrentMonth(currentMonth + 1) + } + + useEffect(() => { + applyColor(uniqueId, { + mainColor: mainColor, + accentColor: accentColor, + reversedColor: reversedColor, + hoverColor: hoverColor, + borderColor: borderColor + }) + }, [mainColor, accentColor, reversedColor, hoverColor, borderColor]) + + useEffect(() => { + const i18n = localization || navigator.language + setL10nDays(getL10Weekday(i18n)) + }, [localization]) + + useEffect(() => { + const dates = getCalendarDates(currentMonth, currentYear) + let weeks: Date[][] = [] + for (let i = 0; i < dates.length; i += 7) + weeks.push(dates.slice(i, i + 7)) + setCalendarWeeks(weeks) + }, [currentMonth, currentYear]) + + useEffect(() => { + if (!value) return + if (!(value instanceof Date)) { + if (value.weekYear < 100) value.weekYear = Number(`20${value.weekYear}`) + if (value.weekNum < 1 || value.weekNum > 53) + return console.warn('Invalid value: Week number should be between 1 and 53.') + } + const date = value instanceof Date ? value : new Date(value.weekYear, 0, 1) + if ('weekNum' in value) + date.setDate(date.getDate() + (value.weekNum - 1) * 7) + setSelectedWeek(calculateWeekNum(date)) + setCurrentMonth(date.getMonth()) + setCurrentYear(date.getFullYear()) + }, [value]) + + function selectWeek(date: Date) { + setSelectedWeek(calculateWeekNum(date)) + onSelect?.(calculateWeekNum(date)) + } + + function changeYear(year: string) { + if (isNaN(Number(year))) return + if (Number(year) < 0) return + setCurrentYear(Number(year)) + } + + function adjustYear() { + if (currentYear < 100) setCurrentYear(Number(`20${currentYear}`)) + } + + if (selectMonth) { + return + } else { + return + } +} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 75a3e89..6319e94 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ import './style.scss' -export {default as SingleDatePicker} from './components/SingleDatePicker' \ No newline at end of file +export {default as SingleDatePicker} from './components/SingleDatePicker' +export {default as SingleWeekPicker} from './components/SingleWeekPicker' \ No newline at end of file diff --git a/src/style.scss b/src/style.scss index d8b02cf..9dde3c2 100644 --- a/src/style.scss +++ b/src/style.scss @@ -7,6 +7,7 @@ background: none; cursor: pointer; color: var(--datenel-main-color); + &:hover { background: var(--datenel-hover-color); } @@ -36,7 +37,7 @@ height: 1rem; } - &.stepper{ + &.stepper { width: 1.75rem; height: 1.75rem; display: flex; @@ -52,68 +53,132 @@ } input.indicator { - border:none; + border: none; background: none; text-align: center; outline: none; border-radius: 0.25rem; padding: 0.25rem 0; color: var(--datenel-main-color); + &:hover { background: var(--datenel-hover-color); } } } - .calendar-view-body { - display: grid; - grid-template-columns: repeat(7, 1fr); - padding: 0.75rem; - gap: 0.125rem; - .item { - width: 2rem; - height: 2rem; + .body { + display: flex; + .calendar-view-body { + padding: 0.75rem; + gap: 0.125rem; + + &.grid { + display: grid; + grid-template-columns: repeat(7, 1fr); + + .item.date { + border-radius: 50%; + + &.extra-month { + cursor: default; + } + + &.active { + background: var(--datenel-accent-color); + color: var(--datenel-reversed-color); + } + } + } + + &.flex { + display: flex; + flex-direction: column; + + .listitem { + padding: 0; + display: grid; + grid-template-columns: repeat(7, 1fr); + border-radius: 1rem; + + .item.date { + background: none; + } + + &.active { + background: var(--datenel-accent-color); + color: var(--datenel-reversed-color); + + .extra-month { + opacity: 0.5; + } + } + } + + } + + .item { + width: 2rem; + height: 2rem; + display: flex; + justify-content: center; + align-items: center; + font-size: 0.75rem; + position: relative; + + &.extra-month { + opacity: 0.3; + } + + &.day-indicator { + opacity: 0.5; + } + + .today-indicator { + position: absolute; + bottom: 0.25rem; + width: 0.25rem; + height: 0.25rem; + } + } + } + + .month-selector-body { + display: grid; + grid-template-columns: repeat(3, 1fr); + padding: 0.75rem; + gap: 0.125rem; + + .item { + border-radius: 0.25rem; + height: 2rem; + } + } + + .week-indicator { display: flex; - justify-content: center; - align-items: center; - font-size: 0.75rem; - } - .item.day-indicator { - opacity: 0.5; - } + flex-direction: column; + border-right: 1px solid var(--datenel-border-color); + gap: 0.125rem; + padding: 0.75rem; - button.item.date { - border-radius: 50%; - position: relative; - &.extra-month { - opacity: 0.3; - cursor: default; - } - &:hover { - background: var(--datenel-hover-color); - } - .today-indicator { - position: absolute; - bottom: 0.25rem; - width: 0.25rem; - height: 0.25rem; + .item { + font-size: 0.75rem; + width: 2rem; + height: 2rem; + display: flex; + justify-content: center; + align-items: center; + border-radius: 50%; } - &.active { + .item.title { + opacity: 0.5; + } + .item.active { background: var(--datenel-accent-color); color: var(--datenel-reversed-color); } } } - - .month-selector-body { - display: grid; - grid-template-columns: repeat(3, 1fr); - padding: 0.75rem; - gap: 0.125rem; - .item { - border-radius: 0.25rem; - height: 2rem; - } - } -} +} \ No newline at end of file diff --git a/src/utils/calculateWeekNum.ts b/src/utils/calculateWeekNum.ts new file mode 100644 index 0000000..35e5428 --- /dev/null +++ b/src/utils/calculateWeekNum.ts @@ -0,0 +1,19 @@ +export default (date: Date): { weekYear: number, weekNum: number } => { + const tempDate = new Date(date) + tempDate.setHours(0, 0, 0, 0) + + tempDate.setDate(tempDate.getDate() + 4 - (tempDate.getDay() || 7)) + + const forthDay = new Date(tempDate.getFullYear(), 0, 4) + console.log(forthDay) + forthDay.setDate(forthDay.getDate() + 4 - (forthDay.getDay() || 7)) + + const diffInDays = Math.floor((tempDate.getTime() - forthDay.getTime()) / (24 * 60 * 60 * 1000)) + + const weekNum = Math.ceil((diffInDays + 1) / 7) + + return { + weekYear: tempDate.getFullYear(), + weekNum + } +} \ No newline at end of file diff --git a/src/utils/index.ts b/src/utils/index.ts index 54fd6db..314d343 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,4 +1,5 @@ export { default as getCalendarDates } from './getCalendarDates' export { default as getL10Weekday } from './getL10Weekday' export { default as generateUniqueId } from './generateUniqueId' -export { default as applyColor } from './applyColor' \ No newline at end of file +export { default as applyColor } from './applyColor' +export { default as calculateWeekNum } from './calculateWeekNum' \ No newline at end of file diff --git a/vite.config.ts b/vite.config.ts index e510078..a316356 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -11,6 +11,7 @@ export default defineConfig(({mode}) => ({ name: "datenel-react", fileName: format => `index.${format}.js`, }, + sourcemap: true, rollupOptions: { preserveEntrySignatures: "strict", external: ["react"],