diff --git a/addon/components/o-s-s/calendar.hbs b/addon/components/o-s-s/calendar.hbs new file mode 100644 index 000000000..4c57f51e3 --- /dev/null +++ b/addon/components/o-s-s/calendar.hbs @@ -0,0 +1,60 @@ +<div class="oss-calendar"> + <div class="calendar-header fx-row fx-gap-px-3"> + {{#if (eq this.currentCalendarView "month")}} + <OSS::Button @icon="fa-left" {{on "click" this.previousMonth}} /> + <h2 {{on "click" this.showMonthsView}}>{{this.currentMonthName}} {{this.currentYear}}</h2> + <OSS::Button @icon="fa-right" {{on "click" this.nextMonth}} /> + {{else}} + <div> + {{log this.yearsRange}} + <OSS::Select @value={{this.currentYear}} @items={{this.yearsRange}} @onChange={{this.changeYear}}> + <:selected as |option|> + <span value="{{this.currentYear}}" style="color:blue">{{this.currentYear}}</span> + </:selected> + <:option as |year|> + {{log year}} + <span value="{{year}}" selected={{eq year this.currentYear}}>{{year}}</span> + </:option> + </OSS::Select> + </div> + {{/if}} + </div> + + {{#if (eq this.currentCalendarView "month")}} + <div class="calendar-table"> + <div class="calendar-weekdays"> + {{#each this.weekDays as |day|}} + <div class="calendar-weekday">{{day}}</div> + {{/each}} + </div> + + <div class="calendar-days"> + {{#each this.calendarDays as |week|}} + <div class="calendar-week"> + {{#each week as |dayObj|}} + {{log dayObj}} + <div + class="calendar-day + {{if (not dayObj.isCurrentMonth) 'opacified'}} + {{if dayObj.isToday 'highlight'}} + {{if dayObj.isSelected 'selected'}}" + {{on "click" (fn this.selectDate dayObj)}} + > + {{dayObj.day}} + </div> + {{/each}} + </div> + {{/each}} + </div> + </div> + + {{else if (eq this.currentCalendarView "months")}} + <div class="months-picker"> + {{#each this.monthsOfYear as |month index|}} + <div class="month-item" {{on "click" (fn this.selectMonth index)}}> + {{month}} + </div> + {{/each}} + </div> + {{/if}} +</div> diff --git a/addon/components/o-s-s/calendar.ts b/addon/components/o-s-s/calendar.ts new file mode 100644 index 000000000..a92154d94 --- /dev/null +++ b/addon/components/o-s-s/calendar.ts @@ -0,0 +1,116 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; +import moment, { type Moment } from 'moment'; + +enum CalendarViewtype { + month = 'month', + months = 'months', + year = 'year' +} + +export default class CalendarComponent extends Component { + @tracked currentDate: Moment = moment(); + @tracked selectedDay: Moment | null = null; + @tracked currentCalendarView: CalendarViewtype = CalendarViewtype.month; + + get yearsRange(): number[] { + const currentYear = moment().year(); + const startYear = currentYear - 20; + const endYear = currentYear + 20; + return Array.from({ length: endYear - startYear + 1 }, (v, i) => startYear + i); + } + + get weekDays(): string[] { + return moment.weekdaysShort(); + } + + get currentMonthName(): string { + return this.currentDate.format('MMMM'); + } + + get currentYear(): number { + return this.currentDate.year(); + } + + get today(): number | null { + const today = moment(); + return today.isSame(this.currentDate, 'month') ? today.date() : null; + } + + get monthsOfYear(): string[] { + return moment.months(); + } + + get calendarDays() { + const startOfMonth = this.currentDate.clone().startOf('month'); + const endOfMonth = this.currentDate.clone().endOf('month'); + const startOfCalendar = startOfMonth.clone().startOf('week'); + const endOfCalendar = endOfMonth.clone().endOf('week'); + const today = moment(); + + const days = []; + let currentDay = startOfCalendar.clone(); + + while (currentDay.isBefore(endOfCalendar)) { + const week = []; + for (let i = 0; i < 7; i++) { + week.push({ + day: currentDay.date(), + isCurrentMonth: currentDay.isSame(this.currentDate, 'month'), + isToday: currentDay.isSame(today, 'day'), + isSelected: this.selectedDay?.isSame(currentDay, 'day') + }); + currentDay.add(1, 'day'); + } + days.push(week); + } + + return days; + } + + @action + selectDate(dayObj: { day: number; isCurrentMonth: boolean; isToday: boolean; isSelected: boolean }) { + if (dayObj.isCurrentMonth) { + this.selectedDay = this.currentDate.clone().date(dayObj.day); + } else if (dayObj.day > 15) { + this.previousMonth(); + this.selectedDay = this.currentDate.clone().endOf('month').date(dayObj.day); + } else { + this.nextMonth(); + this.selectedDay = this.currentDate.clone().startOf('month').date(dayObj.day); + } + + console.log(this.selectedDay); + } + + @action + previousMonth() { + this.currentDate = this.currentDate.clone().subtract(1, 'month'); + this.selectedDay = null; + } + + @action + nextMonth() { + this.currentDate = this.currentDate.clone().add(1, 'month'); + this.selectedDay = null; + } + + @action + showMonthsView() { + this.currentCalendarView = CalendarViewtype.months; + } + + @action + selectMonth(monthIndex: number) { + this.currentDate = this.currentDate.clone().month(monthIndex); + this.currentCalendarView = CalendarViewtype.month; + } + + @action + changeYear(event: Event) { + console.log(event); + const value = event as unknown as number; + this.currentDate = this.currentDate.clone().year(value); + } +} diff --git a/app/components/o-s-s/calendar.js b/app/components/o-s-s/calendar.js new file mode 100644 index 000000000..568126b54 --- /dev/null +++ b/app/components/o-s-s/calendar.js @@ -0,0 +1 @@ +export { default } from '@upfluence/oss-components/components/o-s-s/calendar'; diff --git a/app/styles/molecules/calendar.less b/app/styles/molecules/calendar.less new file mode 100644 index 000000000..8b2a2f41b --- /dev/null +++ b/app/styles/molecules/calendar.less @@ -0,0 +1,129 @@ +.oss-calendar { + font-family: Arial, sans-serif; + max-width: 400px; + margin: 20px auto; + border: 1px solid #ccc; + border-radius: 8px; + padding: 10px; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); + .oss-select-container { + width: 120px; + } +} + +.calendar-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; +} + +.calendar-header h2 { + font-size: 1.5rem; + font-weight: bold; + margin: 0; +} + +.calendar-table { + display: grid; + grid-template-rows: auto 1fr; +} + +.calendar-weekdays { + display: grid; + grid-template-columns: repeat(7, 1fr); /* 7 columns for 7 days */ + text-align: center; + margin-bottom: 5px; + font-weight: bold; + font-size: 0.9rem; + background-color: #f7f7f7; + border-bottom: 1px solid #ccc; +} + +.calendar-weekday { + padding: 10px 0; + border-right: 1px solid #ccc; +} + +.calendar-weekday:last-child { + border-right: none; +} + +.calendar-days { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 2px; +} + +.calendar-week { + display: contents; +} + +.calendar-day { + padding: 10px; + text-align: center; + cursor: pointer; + background-color: #fff; + border: 1px solid #ddd; + border-radius: 4px; + transition: background-color 0.2s, color 0.2s; +} + +.calendar-day:hover { + background-color: #f0f0f0; +} + +.calendar-day.selected { + background-color: var(--color-primary-500); + color: white; +} + +.calendar-day.today { + background-color: #ffeeba; + font-weight: bold; +} + +.calendar-day.opacified { + opacity: 0.4; +} + +.months-picker { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 10px; + padding: 10px; +} + +.month-item { + padding: 15px; + text-align: center; + cursor: pointer; + background-color: #fff; + border: 1px solid var(--color-gray-50); + border-radius: 4px; + transition: background-color 0.2s; +} + +.month-item:hover { + background-color: #f0f0f0; +} + +.calendar-day.highlight { + background-color: lighten(@upf-primary-orange, 40%); + color: white; + font-weight: bold; + border: 1px solid lighten(@upf-primary-orange, 40%); +} + +.calendar-day.selected { + background-color: var(--color-primary-500); + color: white; + font-weight: bold; + border: 1px solid #007bff; +} + +@media (max-width: 400px) { + .calendar-day { + padding: 5px; + } +} diff --git a/app/styles/oss-components.less b/app/styles/oss-components.less index c5b6356b3..3260ecc34 100644 --- a/app/styles/oss-components.less +++ b/app/styles/oss-components.less @@ -50,6 +50,7 @@ @import 'molecules/togglable-section'; @import 'molecules/password-input'; @import 'molecules/avatar-group'; +@import 'molecules/calendar'; @import 'organisms/table'; @import 'organisms/dialog'; @import 'organisms/modal-dialog'; diff --git a/package.json b/package.json index 686cfb5b4..c208bba5a 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "ember-truth-helpers": "^3.1.1", "ion-rangeslider": "^2.3.1", "money-formatter": "^0.1.4", + "moment": "^2.29.4", "resolve": "^1.22.8" }, "peerDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 13d1ba5f0..c26476591 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -62,6 +62,9 @@ dependencies: ion-rangeslider: specifier: ^2.3.1 version: 2.3.1(jquery@3.7.1) + moment: + specifier: ^2.29.4 + version: 2.30.1 money-formatter: specifier: ^0.1.4 version: 0.1.4 @@ -10520,7 +10523,7 @@ packages: resolution: {integrity: sha512-oWb1Z6mkHIskLzEJ/XWX0srkpkTQ7vaopMQkyaEIoq0fmtFVxOthb8cCxeT+p3ynTdkk/RZwbgG4brR5BeWECw==} engines: {node: '>= 4.0'} os: [darwin] - deprecated: The v1 package contains DANGEROUS / INSECURE binaries. Upgrade to safe fsevents v2 + deprecated: Upgrade to fsevents v2 to mitigate potential security issues requiresBuild: true dependencies: bindings: 1.5.0 @@ -13081,6 +13084,10 @@ packages: resolution: {integrity: sha512-IXnMcJ6ZyTuhRmJSjzvHSRhlVPiN9Jwc6e59V0bEJ0ba6OBeX2L0E+mRN1QseeOF4mM+F1Rit6Nh7o+rl2Yn/A==} engines: {node: '>0.9'} + /moment@2.30.1: + resolution: {integrity: sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==} + dev: false + /money-formatter@0.1.4: resolution: {integrity: sha512-0Mw0ztk+nPzyz+m/6lO/ejy2taFpG0OhHyBRRNdpn7EFd0eHHpa053/vNoBPqxVYWd77uJ8BjnVytKMhryMg5g==} dev: false diff --git a/tests/dummy/app/templates/application.hbs b/tests/dummy/app/templates/application.hbs index e60a0032e..0b14d4208 100644 --- a/tests/dummy/app/templates/application.hbs +++ b/tests/dummy/app/templates/application.hbs @@ -29,6 +29,7 @@ </OSS::Layout::Sidebar> <div style="width:100%; height:100vh; overflow: auto; background-color: var(--color-gray-50)"> + <OSS::Calendar /> <div class="fx-row fx-gap-px-10 margin-md"> <OSS::Copy @value="I am the value copied" @inline={{true}} /> <OSS::Copy @value="I am the value copied" /> @@ -1110,4 +1111,4 @@ @icon="fa-circle-info" @skin="error" /> -{{/if}} \ No newline at end of file +{{/if}} diff --git a/tests/integration/components/o-s-s/calendar-test.ts b/tests/integration/components/o-s-s/calendar-test.ts new file mode 100644 index 000000000..0da362559 --- /dev/null +++ b/tests/integration/components/o-s-s/calendar-test.ts @@ -0,0 +1,26 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import { render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; + +module('Integration | Component | o-s-s/calendar', function (hooks) { + setupRenderingTest(hooks); + + test('it renders', async function (assert) { + // Set any properties with this.set('myProperty', 'value'); + // Handle any actions with this.set('myAction', function (val) { ... }); + + await render(hbs`<OSS::Calendar />`); + + assert.dom().hasText(''); + + // Template block usage: + await render(hbs` + <OSS::Calendar> + template block text + </OSS::Calendar> + `); + + assert.dom().hasText('template block text'); + }); +});