Skip to content

UIEUS-420: Create monthpicker #555

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 20 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
4cb68be
UIEUS-420-create-monthpicker
elsenhans Jun 2, 2025
33f14b4
UIEUS-420-create-monthpicker add test
elsenhans Jun 2, 2025
49b49df
UIEUS-420-create-monthpicker
elsenhans Jun 3, 2025
8a84996
Merge branch 'master' into UIEUS-420-create-monthpicker
elsenhans Jun 3, 2025
69ba4de
UIEUS-420-create-monthpicker fix issues
elsenhans Jun 3, 2025
cdf54bc
UIEUS-420-create-monthpicker use userEvent instead of fireEvent
elsenhans Jun 4, 2025
23d6142
UIEUS-420-create-monthpicker improve accessibility
elsenhans Jun 4, 2025
e029492
Merge branch 'master' into UIEUS-420-create-monthpicker
elsenhans Jun 5, 2025
895be8f
UIEUS-420-create-monthpicker improve accessability
elsenhans Jun 5, 2025
0e29b02
UIEUS-420-create-monthpicker solve/disable eslint rules, add esc command
elsenhans Jun 10, 2025
391b4df
UIEUS-420-create-monthpicker disable eslint rules
elsenhans Jun 10, 2025
2380fd2
UIEUS-420-create-monthpicker disable eslint rules
elsenhans Jun 10, 2025
2968616
UIEUS-420-create-monthpicker disable eslint rules
elsenhans Jun 10, 2025
124d959
Merge branch 'master' into UIEUS-420-create-monthpicker
elsenhans Jun 10, 2025
3de3178
UIEUS-420-create-monthpicker inprove date handling
elsenhans Jun 16, 2025
efc2f16
UIEUS-420-create-monthpicker fix test, resolve issues
elsenhans Jun 16, 2025
0cb40dd
UIEUS-420-create-monthpicker add README
elsenhans Jun 16, 2025
bf740e7
UIEUS-420-create-monthpicker adapt date transformation
elsenhans Jun 17, 2025
fcb4c19
UIEUS-420-create-monthpicker separate tests for valid and invald inputs
elsenhans Jun 17, 2025
5cef99a
UIEUS-420-create-monthpicker add yarn.lock
elsenhans Jun 17, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
* New order of UI elements within the edit mode of a UDP ([UIEUS-417](https://folio-org.atlassian.net/browse/UIEUS-417))
* NFR: Check combination between provider status and harvesting status ([UIEUS-418](https://folio-org.atlassian.net/browse/UIEUS-418))
* Update upload form for manual upload of COUNTER reports ([UIEUS-421](https://folio-org.atlassian.net/browse/UIEUS-421))
* Create monthpicker component based on stripes ([UIEUS-420](https://folio-org.atlassian.net/browse/UIEUS-420))

## [11.0.1](https://github.com/folio-org/ui-erm-usage/tree/v11.0.1) (2025-04-11)
* Fix settings aggregator form ([UIEUS-411](https://folio-org.atlassian.net/browse/UIEUS-411))
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
"final-form": "^4.18.7",
"final-form-arrays": "^3.0.2",
"lodash": "^4.17.4",
"luxon": "^3.4.4",
"moment-timezone": "^0.5.14",
"prop-types": "^15.6.0",
"react-dropzone": "^10.2.2",
Expand Down
44 changes: 44 additions & 0 deletions src/util/Monthpicker/Monthpicker.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
.calendar {
position: relative;
background: #fff;
border-radius: var(--radius);
border: 1px solid #bcbcbc;
padding: var(--gutter-static);
box-shadow: var(--shadow);
z-index: 9999;
pointer-events: all;
width: 300px;
}

.calendarHeader {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
font-size: 14px;
}

.marginBottom {
margin-bottom: 1rem;
}

.calendarMonths {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.5rem;
max-width: 300px;

button {
font-weight: 600;
margin-left: 0 !important;
text-align: center;
width: 100%;

&:active,
&:hover,
&:focus,
&:target {
text-decoration: none;
}
}
}
296 changes: 296 additions & 0 deletions src/util/Monthpicker/Monthpicker.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,296 @@
import { PropTypes } from 'prop-types';
import {
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import {
useIntl,
FormattedMessage,
} from 'react-intl';
import { DateTime } from 'luxon';

import {
Button,
Col,
HasCommand,
IconButton,
Popper,
TextField,
} from '@folio/stripes/components';

import css from './Monthpicker.css';

const Monthpicker = ({
backendDateFormat = 'yyyy-MM',
dateFormat,
input,
isRequired,
meta,
textLabel = '',
}) => {
const [showCalendar, setShowCalendar] = useState(false);
const lastValidDateRef = useRef({ year: null, month: null });
const container = useRef(null);
const intl = useIntl();

const normalizeLuxonFormat = (format) => {
return format
.replace(/Y/g, 'y')
.replace(/m/g, 'M');
};

const getDateFormatFromLocale = (locale) => {
const parts = new Intl.DateTimeFormat(locale, {
year: 'numeric',
month: '2-digit',
}).formatToParts(new Date());

return parts
.map((part) => {
if (part.type === 'month') return 'MM';
if (part.type === 'year') return 'yyyy';
return part.value;
})
.join('');
};

const resolvedDateFormat = useMemo(() => {
const localDate = getDateFormatFromLocale(intl.locale);
return normalizeLuxonFormat(dateFormat ?? localDate);
}, [dateFormat, intl.locale]);

const resolvedBackendDateFormat = normalizeLuxonFormat(backendDateFormat);

const isValidYear = (year) => {
const num = Number(year);
return Number.isInteger(num) && num >= 1000 && num <= 9999;
};

// const isValidMonth = (month) => {
// const monthIndex = month - 1;
// return !Number.isNaN(monthIndex) && monthIndex >= 0 && monthIndex <= 11;
// };

// const ensureValidMonth = (month) => {
// if (isValidMonth(month)) return month;
// return new Date().getMonth() + 1;
// };

const ensureValidYear = (year) => {
if (isValidYear(year)) return year;
return new Date().getFullYear();
};

const buildDateString = (year, month, format) => {
const dt = DateTime.fromObject({ year: ensureValidYear(year), month });
return dt.toFormat(format);
};

useEffect(() => {
const backendFormat = normalizeLuxonFormat(backendDateFormat);
const dt = DateTime.fromFormat(input?.value, backendFormat);

if (dt.isValid) {
lastValidDateRef.current = {
year: dt.year,
month: dt.month
};
} else if (!lastValidDateRef.current.year || !lastValidDateRef.current.month) {
const now = DateTime.local();
lastValidDateRef.current = { year: now.year, month: now.month };
}
}, [input?.value, backendDateFormat]);

const getLocalizedMonthAbbreviations = () => {
return Array.from({ length: 12 }, (_, i) => {
return new Intl.DateTimeFormat(intl.locale, { month: 'short' }).format(new Date(2000, i, 1));
});
};

const fromBackendFormat = (backendValue) => {
const dt = DateTime.fromFormat(backendValue, resolvedBackendDateFormat);
return dt.isValid ? dt.toFormat(resolvedDateFormat) : backendValue;
};

const toBackendFormat = (inputValue) => {
const dt = DateTime.fromFormat(inputValue, resolvedDateFormat);
return dt.isValid ? dt.toFormat(resolvedBackendDateFormat) : inputValue;
};

const handleMonthSelect = (monthIndex) => {
const year = lastValidDateRef.current.year ?? new Date().getFullYear();
const dt = DateTime.fromObject({ year, month: monthIndex + 1 });
input.onChange(dt.toFormat(resolvedBackendDateFormat));

setShowCalendar(false);

lastValidDateRef.current = { year, month: monthIndex + 1 };
};

const handleYearChange = (e) => {
lastValidDateRef.current = { ...lastValidDateRef.current, year: ensureValidYear(e.target.value) };
};

const decrementYear = () => {
const newYear = lastValidDateRef.current?.year - 1;
const currentMonth = lastValidDateRef.current?.month;
lastValidDateRef.current = { month: currentMonth, year: newYear };

const newValue = buildDateString(newYear, currentMonth, resolvedDateFormat);
input.onChange(newValue);
};

const incrementYear = () => {
const newYear = lastValidDateRef.current?.year + 1;
const currentMonth = lastValidDateRef.current?.month;
lastValidDateRef.current = { month: currentMonth, year: newYear };

const newValue = buildDateString(newYear, currentMonth, resolvedDateFormat);
input.onChange(newValue);
};

const toggleCalendar = () => {
setShowCalendar(cur => !cur);
};

const shortcuts = [
{
name: 'close',
handler: () => setShowCalendar(false),
shortcut: 'esc',
},
];

const renderEndElement = () => (
<IconButton
aria-haspopup="true"
icon="calendar"
id="monthpicker-toggle-calendar-button"
onClick={toggleCalendar}
/>
);

const content =
<div ref={container}>
<TextField
aria-label={intl.formatMessage({ id: 'ui-erm-usage.monthpicker.yearMonthInput' })}
endControl={renderEndElement()}
error={meta.touched ? meta.error : undefined}
label={textLabel}
name={input.name}
onBlur={input.onBlur}
onChange={(e) => input.onChange(toBackendFormat(e.target.value))}
onFocus={input.onFocus}
placeholder={resolvedDateFormat}
required={isRequired}
value={fromBackendFormat(input.value)}
/>
</div>;

const months = getLocalizedMonthAbbreviations();

const renderCalendar = () => (
<HasCommand
commands={shortcuts}
scope={document.body}
>
{/* Popper component requires a 'div', which is why 'dialog' can not be used here and 'role' is set instead */}
{/* eslint-disable-next-line */}
<div
aria-label={intl.formatMessage({ id: 'ui-erm-usage.monthpicker.yearMonthSelection' })}
className={css.calendar}
role="dialog"
>
<fieldset className={css.calendarHeader}>
<legend className="sr-only">
{intl.formatMessage({ id: 'ui-erm-usage.monthpicker.yearSelection' })}
</legend>
<FormattedMessage id="stripes-components.goToPreviousYear">
{([ariaLabel]) => (
<IconButton
aria-label={ariaLabel}
className={css.marginBottom}
icon="chevron-double-left"
onClick={decrementYear}
/>
)}
</FormattedMessage>
<Col xs={4}>
<FormattedMessage id="stripes-components.Datepicker.yearControl">
{([ariaLabel]) => (
<TextField
aria-label={ariaLabel}
hasClearIcon={false}
type="number"
placeholder={(resolvedDateFormat.match(/y+/) || [])[0]}
value={lastValidDateRef.current?.year}
onChange={e => handleYearChange(e)}
/>
)}
</FormattedMessage>
</Col>
<FormattedMessage id="stripes-components.goToNextYear">
{([ariaLabel]) => (
<IconButton
aria-label={ariaLabel}
className={css.marginBottom}
icon="chevron-double-right"
onClick={incrementYear}
/>
)}
</FormattedMessage>
</fieldset>

<div
aria-label={intl.formatMessage({ id: 'ui-erm-usage.monthpicker.monthSelection' })}
className={css.calendarMonths}
role="grid"
>
{months.map((monthButton, index) => (
// using table is not wanted here, instead using 'row' and 'gridcell' and set a role
// eslint-disable-next-line
<div role="row" key={monthButton}>
{/* eslint-disable-next-line */}
<div role="gridcell">
<Button
aria-label={index + 1 === lastValidDateRef.current.month ? `${monthButton} selected` : monthButton}
aria-pressed={index + 1 === lastValidDateRef.current.month}
buttonStyle={index + 1 === lastValidDateRef.current.month ? 'primary' : ''}
onClick={() => handleMonthSelect(index)}
>
{monthButton}
</Button>
</div>
</div>
))}
</div>
</div>
</HasCommand>
);

return (
<div>
{content}
<Popper
anchorRef={container}
isOpen={showCalendar}
onToggle={toggleCalendar}
>
{renderCalendar()}
</Popper>
</div>
);
};

Monthpicker.propTypes = {
backendDateFormat: PropTypes.string,
dateFormat: PropTypes.string,
input: PropTypes.object,
isRequired: PropTypes.bool,
meta: PropTypes.object,
textLabel: PropTypes.string,
};

export default Monthpicker;
Loading