fix
@@ -0,0 +1,279 @@
|
|||||||
|
.ethiopian-picker-surface .calendars {
|
||||||
|
width: 100% !important;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
color: rgb(31 41 55);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ethiopian-picker-surface .calendars-nav,
|
||||||
|
.ethiopian-picker-surface .calendars-ctrl {
|
||||||
|
background: transparent;
|
||||||
|
border-bottom: 1px solid rgb(229 231 235);
|
||||||
|
padding: 0.35rem 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ethiopian-picker-surface .calendars-month {
|
||||||
|
width: 100%;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ethiopian-picker-surface .calendars-month-header,
|
||||||
|
.ethiopian-picker-surface .calendars-month-header select,
|
||||||
|
.ethiopian-picker-surface .calendars-month-header input {
|
||||||
|
height: 2.1rem;
|
||||||
|
background: transparent;
|
||||||
|
color: rgb(17 24 39);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ethiopian-picker-surface .calendars-month th,
|
||||||
|
.ethiopian-picker-surface .calendars-month td {
|
||||||
|
border: 0;
|
||||||
|
padding: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ethiopian-picker-surface .calendars-month th {
|
||||||
|
color: rgb(107 114 128);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ethiopian-picker-surface .calendars-month a,
|
||||||
|
.ethiopian-picker-surface .calendars-month span {
|
||||||
|
border-radius: 0.35rem;
|
||||||
|
background: transparent;
|
||||||
|
color: rgb(17 24 39);
|
||||||
|
padding: 0.38rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ethiopian-picker-surface .calendars-month a:hover {
|
||||||
|
background: rgb(220 252 231);
|
||||||
|
color: rgb(22 101 52);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ethiopian-picker-surface .calendars-month td .calendars-today {
|
||||||
|
background: rgb(220 252 231);
|
||||||
|
color: rgb(22 101 52);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ethiopian-picker-surface .calendars-month td .calendars-selected {
|
||||||
|
background: rgb(34 197 94);
|
||||||
|
color: rgb(255 255 255);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ethiopian-picker-surface .calendars-month td .calendars-other-month {
|
||||||
|
color: rgb(156 163 175);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ethiopian-picker-surface .calendars-nav .calendars-cmd-prev,
|
||||||
|
.ethiopian-picker-surface .calendars-nav .calendars-cmd-next,
|
||||||
|
.ethiopian-picker-surface .calendars-nav .calendars-cmd-prevJump,
|
||||||
|
.ethiopian-picker-surface .calendars-nav .calendars-cmd-nextJump,
|
||||||
|
.calendars-popup .calendars-nav .calendars-cmd-prev,
|
||||||
|
.calendars-popup .calendars-nav .calendars-cmd-next,
|
||||||
|
.calendars-popup .calendars-nav .calendars-cmd-prevJump,
|
||||||
|
.calendars-popup .calendars-nav .calendars-cmd-nextJump {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
border: 1px solid rgb(187 247 208);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: rgb(240 253 244);
|
||||||
|
color: rgb(21 128 61);
|
||||||
|
line-height: 1;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ethiopian-picker-surface .calendars-nav .calendars-cmd-prev:hover,
|
||||||
|
.ethiopian-picker-surface .calendars-nav .calendars-cmd-next:hover,
|
||||||
|
.ethiopian-picker-surface .calendars-nav .calendars-cmd-prevJump:hover,
|
||||||
|
.ethiopian-picker-surface .calendars-nav .calendars-cmd-nextJump:hover,
|
||||||
|
.calendars-popup .calendars-nav .calendars-cmd-prev:hover,
|
||||||
|
.calendars-popup .calendars-nav .calendars-cmd-next:hover,
|
||||||
|
.calendars-popup .calendars-nav .calendars-cmd-prevJump:hover,
|
||||||
|
.calendars-popup .calendars-nav .calendars-cmd-nextJump:hover {
|
||||||
|
background: rgb(34 197 94);
|
||||||
|
border-color: rgb(34 197 94);
|
||||||
|
color: rgb(255 255 255);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ethiopian-picker-surface .calendars-nav,
|
||||||
|
.calendars-popup .calendars-nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ethiopian-picker-surface .calendars-nav .calendars-cmd,
|
||||||
|
.calendars-popup .calendars-nav .calendars-cmd {
|
||||||
|
float: none;
|
||||||
|
width: auto;
|
||||||
|
padding-left: 0;
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ethiopian-picker-surface .calendars-nav .calendars-cmd-today,
|
||||||
|
.calendars-popup .calendars-nav .calendars-cmd-today {
|
||||||
|
flex: 1;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
color: rgb(21 128 61);
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ethiopian-picker-surface .calendars-nav .calendars-cmd.calendars-disabled,
|
||||||
|
.calendars-popup .calendars-nav .calendars-cmd.calendars-disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .ethiopian-picker-surface .calendars,
|
||||||
|
[data-theme="dark"] .ethiopian-picker-surface .calendars,
|
||||||
|
.dark .calendars-popup .calendars,
|
||||||
|
[data-theme="dark"] .calendars-popup .calendars {
|
||||||
|
background: rgb(17 24 39);
|
||||||
|
color: rgb(229 231 235);
|
||||||
|
border-color: rgb(55 65 81);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .ethiopian-picker-surface .calendars-nav,
|
||||||
|
.dark .ethiopian-picker-surface .calendars-ctrl,
|
||||||
|
[data-theme="dark"] .ethiopian-picker-surface .calendars-nav,
|
||||||
|
[data-theme="dark"] .ethiopian-picker-surface .calendars-ctrl,
|
||||||
|
.dark .calendars-popup .calendars-nav,
|
||||||
|
.dark .calendars-popup .calendars-ctrl,
|
||||||
|
[data-theme="dark"] .calendars-popup .calendars-nav,
|
||||||
|
[data-theme="dark"] .calendars-popup .calendars-ctrl {
|
||||||
|
background: rgb(17 24 39);
|
||||||
|
border-color: rgb(55 65 81);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .ethiopian-picker-surface .calendars-month-header,
|
||||||
|
.dark .ethiopian-picker-surface .calendars-month-header select,
|
||||||
|
.dark .ethiopian-picker-surface .calendars-month-header input,
|
||||||
|
.dark .ethiopian-picker-surface .calendars-month a,
|
||||||
|
.dark .ethiopian-picker-surface .calendars-month span,
|
||||||
|
[data-theme="dark"] .ethiopian-picker-surface .calendars-month-header,
|
||||||
|
[data-theme="dark"] .ethiopian-picker-surface .calendars-month-header select,
|
||||||
|
[data-theme="dark"] .ethiopian-picker-surface .calendars-month-header input,
|
||||||
|
[data-theme="dark"] .ethiopian-picker-surface .calendars-month a,
|
||||||
|
[data-theme="dark"] .ethiopian-picker-surface .calendars-month span,
|
||||||
|
.dark .calendars-popup .calendars-month-header,
|
||||||
|
.dark .calendars-popup .calendars-month-header select,
|
||||||
|
.dark .calendars-popup .calendars-month-header input,
|
||||||
|
.dark .calendars-popup .calendars-month a,
|
||||||
|
.dark .calendars-popup .calendars-month span,
|
||||||
|
[data-theme="dark"] .calendars-popup .calendars-month-header,
|
||||||
|
[data-theme="dark"] .calendars-popup .calendars-month-header select,
|
||||||
|
[data-theme="dark"] .calendars-popup .calendars-month-header input,
|
||||||
|
[data-theme="dark"] .calendars-popup .calendars-month a,
|
||||||
|
[data-theme="dark"] .calendars-popup .calendars-month span {
|
||||||
|
color: rgb(229 231 235);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .ethiopian-picker-surface .calendars-month th,
|
||||||
|
[data-theme="dark"] .ethiopian-picker-surface .calendars-month th,
|
||||||
|
.dark .calendars-popup .calendars-month th,
|
||||||
|
[data-theme="dark"] .calendars-popup .calendars-month th {
|
||||||
|
color: rgb(156 163 175);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .ethiopian-picker-surface .calendars-month a:hover,
|
||||||
|
[data-theme="dark"] .ethiopian-picker-surface .calendars-month a:hover,
|
||||||
|
.dark .calendars-popup .calendars-month a:hover,
|
||||||
|
[data-theme="dark"] .calendars-popup .calendars-month a:hover {
|
||||||
|
background: rgb(20 83 45);
|
||||||
|
color: rgb(187 247 208);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .ethiopian-picker-surface .calendars-month td .calendars-today,
|
||||||
|
[data-theme="dark"] .ethiopian-picker-surface .calendars-month td .calendars-today,
|
||||||
|
.dark .calendars-popup .calendars-month td .calendars-today,
|
||||||
|
[data-theme="dark"] .calendars-popup .calendars-month td .calendars-today {
|
||||||
|
background: rgb(20 83 45);
|
||||||
|
color: rgb(187 247 208);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .ethiopian-picker-surface .calendars-month td .calendars-highlight,
|
||||||
|
[data-theme="dark"] .ethiopian-picker-surface .calendars-month td .calendars-highlight,
|
||||||
|
.dark .calendars-popup .calendars-month td .calendars-highlight,
|
||||||
|
[data-theme="dark"] .calendars-popup .calendars-month td .calendars-highlight {
|
||||||
|
background: rgb(22 101 52);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .ethiopian-picker-surface .calendars-month td .calendars-selected,
|
||||||
|
[data-theme="dark"] .ethiopian-picker-surface .calendars-month td .calendars-selected,
|
||||||
|
.dark .calendars-popup .calendars-month td .calendars-selected,
|
||||||
|
[data-theme="dark"] .calendars-popup .calendars-month td .calendars-selected {
|
||||||
|
background: rgb(34 197 94);
|
||||||
|
color: rgb(5 46 22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .ethiopian-picker-surface .calendars-month td .calendars-other-month,
|
||||||
|
[data-theme="dark"] .ethiopian-picker-surface .calendars-month td .calendars-other-month,
|
||||||
|
.dark .calendars-popup .calendars-month td .calendars-other-month,
|
||||||
|
[data-theme="dark"] .calendars-popup .calendars-month td .calendars-other-month {
|
||||||
|
color: rgb(107 114 128);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .ethiopian-picker-surface .calendars a.calendars-disabled,
|
||||||
|
[data-theme="dark"] .ethiopian-picker-surface .calendars a.calendars-disabled,
|
||||||
|
.dark .calendars-popup .calendars a.calendars-disabled,
|
||||||
|
[data-theme="dark"] .calendars-popup .calendars a.calendars-disabled {
|
||||||
|
color: rgb(75 85 99);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .ethiopian-picker-surface .calendars-nav .calendars-cmd-prev,
|
||||||
|
.dark .ethiopian-picker-surface .calendars-nav .calendars-cmd-next,
|
||||||
|
.dark .ethiopian-picker-surface .calendars-nav .calendars-cmd-prevJump,
|
||||||
|
.dark .ethiopian-picker-surface .calendars-nav .calendars-cmd-nextJump,
|
||||||
|
[data-theme="dark"] .ethiopian-picker-surface .calendars-nav .calendars-cmd-prev,
|
||||||
|
[data-theme="dark"] .ethiopian-picker-surface .calendars-nav .calendars-cmd-next,
|
||||||
|
[data-theme="dark"] .ethiopian-picker-surface .calendars-nav .calendars-cmd-prevJump,
|
||||||
|
[data-theme="dark"] .ethiopian-picker-surface .calendars-nav .calendars-cmd-nextJump,
|
||||||
|
.dark .calendars-popup .calendars-nav .calendars-cmd-prev,
|
||||||
|
.dark .calendars-popup .calendars-nav .calendars-cmd-next,
|
||||||
|
.dark .calendars-popup .calendars-nav .calendars-cmd-prevJump,
|
||||||
|
.dark .calendars-popup .calendars-nav .calendars-cmd-nextJump,
|
||||||
|
[data-theme="dark"] .calendars-popup .calendars-nav .calendars-cmd-prev,
|
||||||
|
[data-theme="dark"] .calendars-popup .calendars-nav .calendars-cmd-next,
|
||||||
|
[data-theme="dark"] .calendars-popup .calendars-nav .calendars-cmd-prevJump,
|
||||||
|
[data-theme="dark"] .calendars-popup .calendars-nav .calendars-cmd-nextJump {
|
||||||
|
background: rgb(21 128 61 / 0.22);
|
||||||
|
border-color: rgb(34 197 94 / 0.45);
|
||||||
|
color: rgb(187 247 208);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .ethiopian-picker-surface .calendars-nav .calendars-cmd-prev:hover,
|
||||||
|
.dark .ethiopian-picker-surface .calendars-nav .calendars-cmd-next:hover,
|
||||||
|
.dark .ethiopian-picker-surface .calendars-nav .calendars-cmd-prevJump:hover,
|
||||||
|
.dark .ethiopian-picker-surface .calendars-nav .calendars-cmd-nextJump:hover,
|
||||||
|
[data-theme="dark"] .ethiopian-picker-surface .calendars-nav .calendars-cmd-prev:hover,
|
||||||
|
[data-theme="dark"] .ethiopian-picker-surface .calendars-nav .calendars-cmd-next:hover,
|
||||||
|
[data-theme="dark"] .ethiopian-picker-surface .calendars-nav .calendars-cmd-prevJump:hover,
|
||||||
|
[data-theme="dark"] .ethiopian-picker-surface .calendars-nav .calendars-cmd-nextJump:hover,
|
||||||
|
.dark .calendars-popup .calendars-nav .calendars-cmd-prev:hover,
|
||||||
|
.dark .calendars-popup .calendars-nav .calendars-cmd-next:hover,
|
||||||
|
.dark .calendars-popup .calendars-nav .calendars-cmd-prevJump:hover,
|
||||||
|
.dark .calendars-popup .calendars-nav .calendars-cmd-nextJump:hover,
|
||||||
|
[data-theme="dark"] .calendars-popup .calendars-nav .calendars-cmd-prev:hover,
|
||||||
|
[data-theme="dark"] .calendars-popup .calendars-nav .calendars-cmd-next:hover,
|
||||||
|
[data-theme="dark"] .calendars-popup .calendars-nav .calendars-cmd-prevJump:hover,
|
||||||
|
[data-theme="dark"] .calendars-popup .calendars-nav .calendars-cmd-nextJump:hover {
|
||||||
|
background: rgb(34 197 94);
|
||||||
|
border-color: rgb(34 197 94);
|
||||||
|
color: rgb(5 46 22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .ethiopian-picker-surface .calendars-nav .calendars-cmd-today,
|
||||||
|
[data-theme="dark"] .ethiopian-picker-surface .calendars-nav .calendars-cmd-today,
|
||||||
|
.dark .calendars-popup .calendars-nav .calendars-cmd-today,
|
||||||
|
[data-theme="dark"] .calendars-popup .calendars-nav .calendars-cmd-today {
|
||||||
|
color: rgb(187 247 208);
|
||||||
|
}
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
/* Flora styling for jQuery Calendars Picker v2.0.0. */
|
||||||
|
.calendars {
|
||||||
|
background-color: #e0f4d7;
|
||||||
|
color: #000;
|
||||||
|
border: 1px solid #f90;
|
||||||
|
-moz-border-radius: 0.25em;
|
||||||
|
-webkit-border-radius: 0.25em;
|
||||||
|
border-radius: 0.25em;
|
||||||
|
font-family: Arial,Helvetica,Sans-serif;
|
||||||
|
font-size: 90%;
|
||||||
|
}
|
||||||
|
.calendars-rtl {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
.calendars-popup {
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
.calendars-disable {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 100;
|
||||||
|
background-color: white;
|
||||||
|
opacity: 0.5;
|
||||||
|
filter: alpha(opacity=50);
|
||||||
|
}
|
||||||
|
.calendars a {
|
||||||
|
color: #000;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.calendars a.calendars-disabled {
|
||||||
|
color: #888;
|
||||||
|
cursor: auto;
|
||||||
|
}
|
||||||
|
.calendars button {
|
||||||
|
margin: 0.25em;
|
||||||
|
padding: 0.125em 0em;
|
||||||
|
background-color: #fc8;
|
||||||
|
border: none;
|
||||||
|
-moz-border-radius: 0.25em;
|
||||||
|
-webkit-border-radius: 0.25em;
|
||||||
|
border-radius: 0.25em;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.calendars-nav, .calendars-ctrl {
|
||||||
|
float: left;
|
||||||
|
width: 100%;
|
||||||
|
background-color: #e0f4d7;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 90%;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.datepick-ctrl {
|
||||||
|
background-color: #f90;
|
||||||
|
}
|
||||||
|
.calendars-cmd {
|
||||||
|
width: 30%;
|
||||||
|
}
|
||||||
|
.calendars-cmd:hover {
|
||||||
|
background-color: #b1db87;
|
||||||
|
}
|
||||||
|
.calendars-ctrl .calendars-cmd:hover {
|
||||||
|
background-color: #fa4;
|
||||||
|
}
|
||||||
|
.calendars-cmd-prevJump, .calendars-cmd-nextJump {
|
||||||
|
width: 8%;
|
||||||
|
}
|
||||||
|
a.calendars-cmd {
|
||||||
|
height: 1.5em;
|
||||||
|
}
|
||||||
|
button.calendars-cmd {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.calendars-cmd-prev, .calendars-cmd-prevJump, .calendars-cmd-clear {
|
||||||
|
float: left;
|
||||||
|
padding-left: 2%;
|
||||||
|
}
|
||||||
|
.calendars-cmd-current, .calendars-cmd-today {
|
||||||
|
float: left;
|
||||||
|
width: 35%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.calendars-cmd-next, .calendars-cmd-nextJump, .calendars-cmd-close {
|
||||||
|
float: right;
|
||||||
|
padding-right: 2%;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.calendars-rtl .calendars-cmd-prev, .calendars-rtl .calendars-cmd-prevJump,
|
||||||
|
.calendars-rtl .calendars-cmd-clear {
|
||||||
|
float: right;
|
||||||
|
padding-left: 0%;
|
||||||
|
padding-right: 2%;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.calendars-rtl .calendars-cmd-current, .calendars-rtl .calendars-cmd-today {
|
||||||
|
float: right;
|
||||||
|
}
|
||||||
|
.calendars-rtl .calendars-cmd-next, .calendars-rtl .calendars-cmd-nextJump,
|
||||||
|
.calendars-rtl .calendars-cmd-close {
|
||||||
|
float: left;
|
||||||
|
padding-left: 2%;
|
||||||
|
padding-right: 0%;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.calendars-month-nav {
|
||||||
|
float: left;
|
||||||
|
background-color: #b1db87;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.calendars-month-nav div {
|
||||||
|
float: left;
|
||||||
|
width: 12.5%;
|
||||||
|
margin: 1%;
|
||||||
|
padding: 1%;
|
||||||
|
}
|
||||||
|
.calendars-month-nav span {
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
.calendars-month-row {
|
||||||
|
clear: left;
|
||||||
|
}
|
||||||
|
.calendars-month {
|
||||||
|
float: left;
|
||||||
|
width: 15em;
|
||||||
|
border: 1px solid #83c948;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.calendars-month-header, .calendars-month-header select, .calendars-month-header input {
|
||||||
|
height: 1.5em;
|
||||||
|
background-color: #83c948;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.calendars-month-header select, .calendars-month-header input {
|
||||||
|
height: 1.4em;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
.calendars-month-header input {
|
||||||
|
position: absolute;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.calendars-month table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
.calendars-month thead {
|
||||||
|
border-bottom: 1px solid #aaa;
|
||||||
|
}
|
||||||
|
.calendars-month th, .calendars-month td {
|
||||||
|
margin: 0em;
|
||||||
|
padding: 0em;
|
||||||
|
font-weight: normal;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.calendars-month th {
|
||||||
|
border: 1px solid #b1db87;
|
||||||
|
}
|
||||||
|
.calendars-month th, .calendars-month th a {
|
||||||
|
background-color: #b1db87;
|
||||||
|
color: #000;
|
||||||
|
border: 1px solid #b1db87;
|
||||||
|
}
|
||||||
|
.calendars-month td {
|
||||||
|
background-color: #fff;
|
||||||
|
color: #666;
|
||||||
|
border: 1px solid #b1db87;
|
||||||
|
}
|
||||||
|
.calendars-month td.calendars-week * {
|
||||||
|
background-color: #b1db87;
|
||||||
|
color: #666;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
.calendars-month a {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.125em 0em;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.calendars-month span {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.125em 0em;
|
||||||
|
}
|
||||||
|
.calendars-month td span {
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
.calendars-month td .calendars-other-month {
|
||||||
|
background-color: #e0f4d7;
|
||||||
|
}
|
||||||
|
.calendars-month td .calendars-weekend {
|
||||||
|
background-color: #e0f4d7;
|
||||||
|
}
|
||||||
|
.calendars-month td .calendars-today {
|
||||||
|
background-color: #b1db87;
|
||||||
|
}
|
||||||
|
.calendars-month td .calendars-highlight {
|
||||||
|
background-color: #fc8;
|
||||||
|
}
|
||||||
|
.calendars-month td .calendars-selected {
|
||||||
|
background-color: #f90;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.calendars-status {
|
||||||
|
clear: both;
|
||||||
|
background-color: #b1db87;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.calendars-clear-fix {
|
||||||
|
clear: both;
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
/* Humanity styling for jQuery Calendars Picker v2.0.0. */
|
||||||
|
.calendars {
|
||||||
|
background-color: #f4f0ec;
|
||||||
|
color: #1e1b1c;
|
||||||
|
border: 1px solid #cb842e;
|
||||||
|
-moz-border-radius: 0.25em;
|
||||||
|
-webkit-border-radius: 0.25em;
|
||||||
|
border-radius: 0.25em;
|
||||||
|
font-family: Arial,Helvetica,Sans-serif;
|
||||||
|
font-size: 90%;
|
||||||
|
}
|
||||||
|
.calendars-rtl {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
.calendars-popup {
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
.calendars-disable {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 100;
|
||||||
|
background-color: white;
|
||||||
|
opacity: 0.5;
|
||||||
|
filter: alpha(opacity=50);
|
||||||
|
}
|
||||||
|
.calendars a {
|
||||||
|
color: #1e1b1c;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.calendars a.calendars-disabled {
|
||||||
|
color: #888;
|
||||||
|
cursor: auto;
|
||||||
|
}
|
||||||
|
.calendars button {
|
||||||
|
margin: 0.25em;
|
||||||
|
padding: 0.125em 0em;
|
||||||
|
background-color: #ede4d4;
|
||||||
|
border: none;
|
||||||
|
-moz-border-radius: 0.25em;
|
||||||
|
-webkit-border-radius: 0.25em;
|
||||||
|
border-radius: 0.25em;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.calendars-nav, .calendars-ctrl {
|
||||||
|
float: left;
|
||||||
|
width: 100%;
|
||||||
|
background-color: #ede4d4;
|
||||||
|
font-size: 90%;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.calendars-ctrl {
|
||||||
|
background-color: #cb842e;
|
||||||
|
}
|
||||||
|
.calendars-cmd {
|
||||||
|
width: 30%;
|
||||||
|
}
|
||||||
|
.calendars-cmd:hover {
|
||||||
|
background-color: #f4f0ec;
|
||||||
|
}
|
||||||
|
.calendars-cmd-prevJump, .calendars-cmd-nextJump {
|
||||||
|
width: 8%;
|
||||||
|
}
|
||||||
|
a.calendars-cmd {
|
||||||
|
height: 1.5em;
|
||||||
|
}
|
||||||
|
button.calendars-cmd {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.calendars-cmd-prev, .calendars-cmd-prevJump, .calendars-cmd-clear {
|
||||||
|
float: left;
|
||||||
|
padding-left: 2%;
|
||||||
|
}
|
||||||
|
.calendars-cmd-current, .calendars-cmd-today {
|
||||||
|
float: left;
|
||||||
|
width: 35%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.calendars-cmd-next, .calendars-cmd-nextJump, .calendars-cmd-close {
|
||||||
|
float: right;
|
||||||
|
padding-right: 2%;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.calendars-rtl .calendars-cmd-prev, .calendars-rtl .calendars-cmd-prevJump,
|
||||||
|
.calendars-rtl .calendars-cmd-clear {
|
||||||
|
float: right;
|
||||||
|
padding-left: 0%;
|
||||||
|
padding-right: 2%;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.calendars-rtl .calendars-cmd-current, .calendars-rtl .calendars-cmd-today {
|
||||||
|
float: right;
|
||||||
|
}
|
||||||
|
.calendars-rtl .calendars-cmd-next, .calendars-rtl .calendars-cmd-nextJump,
|
||||||
|
.calendars-rtl .calendars-cmd-close {
|
||||||
|
float: left;
|
||||||
|
padding-left: 2%;
|
||||||
|
padding-right: 0%;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.calendars-month-nav {
|
||||||
|
float: left;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.calendars-month-nav div {
|
||||||
|
float: left;
|
||||||
|
width: 12.5%;
|
||||||
|
margin: 1%;
|
||||||
|
padding: 1%;
|
||||||
|
}
|
||||||
|
.calendars-month-nav span {
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
.calendars-month-row {
|
||||||
|
clear: left;
|
||||||
|
}
|
||||||
|
.calendars-month {
|
||||||
|
float: left;
|
||||||
|
width: 17em;
|
||||||
|
border: 1px solid #e0cfc2;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.calendars-month-header, .calendars-month-header select, .calendars-month-header input {
|
||||||
|
height: 1.5em;
|
||||||
|
background-color: #cb842e;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.calendars-month-header select, .calendars-month-header input {
|
||||||
|
height: 1.4em;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
.calendars-month-header input {
|
||||||
|
position: absolute;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.calendars-month table {
|
||||||
|
width: 100%;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
.calendars-month th, .calendars-month td {
|
||||||
|
margin: 0em;
|
||||||
|
padding: 0.125em;
|
||||||
|
font-weight: normal;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.calendars-month td.calendars-week,
|
||||||
|
.calendars-month td.calendars-week * {
|
||||||
|
background-color: #cb842e;
|
||||||
|
color: #fff;
|
||||||
|
border: 1px solid #cb842e;
|
||||||
|
}
|
||||||
|
.calendars-month a {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.125em 0em;
|
||||||
|
background-color: #ede4d4;
|
||||||
|
color: #000;
|
||||||
|
border: 1px solid #cdc3b7;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.calendars-month span {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.25em;
|
||||||
|
}
|
||||||
|
.calendars-month a {
|
||||||
|
background-color: #ede4d4;
|
||||||
|
color: #444;
|
||||||
|
border: 1px solid #cdc3b7;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.calendars-month td span {
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
.calendars-month td .calendars-other-month {
|
||||||
|
background-color: #f4f0ec;
|
||||||
|
}
|
||||||
|
.calendars-month td .calendars-today {
|
||||||
|
background-color: #f5f5b5;
|
||||||
|
border: 1px solid #d9bb73;
|
||||||
|
}
|
||||||
|
.calendars-month td .calendars-highlight {
|
||||||
|
background-color: #f5f0e5;
|
||||||
|
color: #1e1b1c;
|
||||||
|
border: 1px solid #f5ad66;
|
||||||
|
}
|
||||||
|
.calendars-month td .calendars-selected {
|
||||||
|
background-color: #cb842e;
|
||||||
|
color: #fff;
|
||||||
|
border: 1px solid #cb842e;
|
||||||
|
}
|
||||||
|
.calendars-status {
|
||||||
|
clear: both;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.calendars-clear-fix {
|
||||||
|
clear: both;
|
||||||
|
}
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
/* Default styling for jQuery Calendars Picker v2.0.0. */
|
||||||
|
.calendars {
|
||||||
|
background-color: #fff;
|
||||||
|
color: #000;
|
||||||
|
border: 1px solid #444;
|
||||||
|
-moz-border-radius: 0.25em;
|
||||||
|
-webkit-border-radius: 0.25em;
|
||||||
|
border-radius: 0.25em;
|
||||||
|
font-family: Arial,Helvetica,Sans-serif;
|
||||||
|
font-size: 90%;
|
||||||
|
}
|
||||||
|
.calendars-rtl {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
.calendars-popup {
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
.calendars-disable {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 100;
|
||||||
|
background-color: white;
|
||||||
|
opacity: 0.5;
|
||||||
|
filter: alpha(opacity=50);
|
||||||
|
}
|
||||||
|
.calendars a {
|
||||||
|
color: #fff;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.calendars a.calendars-disabled {
|
||||||
|
color: #888;
|
||||||
|
cursor: auto;
|
||||||
|
}
|
||||||
|
.calendars button {
|
||||||
|
margin: 0.25em;
|
||||||
|
padding: 0.125em 0em;
|
||||||
|
background-color: #fcc;
|
||||||
|
border: none;
|
||||||
|
-moz-border-radius: 0.25em;
|
||||||
|
-webkit-border-radius: 0.25em;
|
||||||
|
border-radius: 0.25em;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.calendars-nav, .calendars-ctrl {
|
||||||
|
float: left;
|
||||||
|
width: 100%;
|
||||||
|
background-color: #000;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 90%;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.calendars-ctrl {
|
||||||
|
background-color: #600;
|
||||||
|
}
|
||||||
|
.calendars-cmd {
|
||||||
|
width: 30%;
|
||||||
|
}
|
||||||
|
.calendars-cmd:hover {
|
||||||
|
background-color: #777;
|
||||||
|
}
|
||||||
|
.calendars-ctrl .calendars-cmd:hover {
|
||||||
|
background-color: #f08080;
|
||||||
|
}
|
||||||
|
.calendars-cmd-prevJump, .calendars-cmd-nextJump {
|
||||||
|
width: 8%;
|
||||||
|
}
|
||||||
|
a.calendars-cmd {
|
||||||
|
height: 1.5em;
|
||||||
|
}
|
||||||
|
button.calendars-cmd {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.calendars-cmd-prev, .calendars-cmd-prevJump, .calendars-cmd-clear {
|
||||||
|
float: left;
|
||||||
|
padding-left: 2%;
|
||||||
|
}
|
||||||
|
.calendars-cmd-current, .calendars-cmd-today {
|
||||||
|
float: left;
|
||||||
|
width: 35%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.calendars-cmd-next, .calendars-cmd-nextJump, .calendars-cmd-close {
|
||||||
|
float: right;
|
||||||
|
padding-right: 2%;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.calendars-rtl .calendars-cmd-prev, .calendars-rtl .calendars-cmd-prevJump,
|
||||||
|
.calendars-rtl .calendars-cmd-clear {
|
||||||
|
float: right;
|
||||||
|
padding-left: 0%;
|
||||||
|
padding-right: 2%;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.calendars-rtl .calendars-cmd-current, .calendars-rtl .calendars-cmd-today {
|
||||||
|
float: right;
|
||||||
|
}
|
||||||
|
.calendars-rtl .calendars-cmd-next, .calendars-rtl .calendars-cmd-nextJump,
|
||||||
|
.calendars-rtl .calendars-cmd-close {
|
||||||
|
float: left;
|
||||||
|
padding-left: 2%;
|
||||||
|
padding-right: 0%;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.calendars-month-nav {
|
||||||
|
float: left;
|
||||||
|
background-color: #777;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.calendars-month-nav div {
|
||||||
|
float: left;
|
||||||
|
width: 12.5%;
|
||||||
|
margin: 1%;
|
||||||
|
padding: 1%;
|
||||||
|
}
|
||||||
|
.calendars-month-nav span {
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
.calendars-month-row {
|
||||||
|
clear: left;
|
||||||
|
}
|
||||||
|
.calendars-month {
|
||||||
|
float: left;
|
||||||
|
width: 15em;
|
||||||
|
border: 1px solid #444;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.calendars-month-header, .calendars-month-header select, .calendars-month-header input {
|
||||||
|
height: 1.5em;
|
||||||
|
background-color: #444;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.calendars-month-header select, .calendars-month-header input {
|
||||||
|
height: 1.4em;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
.calendars-month-header input {
|
||||||
|
position: absolute;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.calendars-month table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
.calendars-month thead {
|
||||||
|
border-bottom: 1px solid #aaa;
|
||||||
|
}
|
||||||
|
.calendars-month th, .calendars-month td {
|
||||||
|
margin: 0em;
|
||||||
|
padding: 0em;
|
||||||
|
font-weight: normal;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.calendars-month th {
|
||||||
|
border: 1px solid #777;
|
||||||
|
}
|
||||||
|
.calendars-month th, .calendars-month th a {
|
||||||
|
background-color: #777;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.calendars-month td {
|
||||||
|
background-color: #eee;
|
||||||
|
border: 1px solid #aaa;
|
||||||
|
}
|
||||||
|
.calendars-month td.calendars-week {
|
||||||
|
border: 1px solid #777;
|
||||||
|
}
|
||||||
|
.calendars-month td.calendars-week * {
|
||||||
|
background-color: #777;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
.calendars-month a {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.125em 0em;
|
||||||
|
background-color: #eee;
|
||||||
|
color: #000;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.calendars-month span {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.125em 0em;
|
||||||
|
}
|
||||||
|
.calendars-month td span {
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
.calendars-month td .calendars-other-month {
|
||||||
|
background-color: #fff;
|
||||||
|
}
|
||||||
|
.calendars-month td .calendars-weekend {
|
||||||
|
background-color: #ddd;
|
||||||
|
}
|
||||||
|
.calendars-month td .calendars-today {
|
||||||
|
background-color: #f0c0c0;
|
||||||
|
}
|
||||||
|
.calendars-month td .calendars-highlight {
|
||||||
|
background-color: #f08080;
|
||||||
|
}
|
||||||
|
.calendars-month td .calendars-selected {
|
||||||
|
background-color: #777;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.calendars-month th.calendars-week {
|
||||||
|
background-color: #777;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.calendars-status {
|
||||||
|
clear: both;
|
||||||
|
background-color: #ddd;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.calendars-clear-fix {
|
||||||
|
clear: both;
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
/* Redmond styling for jQuery Calendars Picker v2.0.0. */
|
||||||
|
.calendars {
|
||||||
|
background-color: #fff;
|
||||||
|
color: #222;
|
||||||
|
border: 1px solid #4297d7;
|
||||||
|
-moz-border-radius: 0.25em;
|
||||||
|
-webkit-border-radius: 0.25em;
|
||||||
|
border-radius: 0.25em;
|
||||||
|
font-family: Arial,Helvetica,Sans-serif;
|
||||||
|
font-size: 90%;
|
||||||
|
}
|
||||||
|
.calendars-rtl {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
.calendars-popup {
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
.calendars-disable {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 100;
|
||||||
|
background-color: white;
|
||||||
|
opacity: 0.5;
|
||||||
|
filter: alpha(opacity=50);
|
||||||
|
}
|
||||||
|
.calendars a {
|
||||||
|
color: #222;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.calendars a.calendars-disabled {
|
||||||
|
color: #888;
|
||||||
|
cursor: auto;
|
||||||
|
}
|
||||||
|
.calendars button {
|
||||||
|
margin: 0.25em;
|
||||||
|
padding: 0.125em 0em;
|
||||||
|
background-color: #5c9ccc;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
-moz-border-radius: 0.25em;
|
||||||
|
-webkit-border-radius: 0.25em;
|
||||||
|
border-radius: 0.25em;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.calendars-nav, .calendars-ctrl {
|
||||||
|
float: left;
|
||||||
|
width: 100%;
|
||||||
|
background-color: #fff;
|
||||||
|
font-size: 90%;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.calendars-ctrl {
|
||||||
|
background-color: #d0e5f5;
|
||||||
|
}
|
||||||
|
.calendars-cmd {
|
||||||
|
width: 30%;
|
||||||
|
}
|
||||||
|
.calendars-cmd:hover {
|
||||||
|
background-color: #dfeffc;
|
||||||
|
}
|
||||||
|
button.calendars-cmd:hover {
|
||||||
|
background-color: #79b7e7;
|
||||||
|
}
|
||||||
|
.calendars-cmd-prevJump, .calendars-cmd-nextJump {
|
||||||
|
width: 8%;
|
||||||
|
}
|
||||||
|
a.calendars-cmd {
|
||||||
|
height: 1.5em;
|
||||||
|
}
|
||||||
|
button.calendars-cmd {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.calendars-cmd-prev, .calendars-cmd-prevJump, .calendars-cmd-clear {
|
||||||
|
float: left;
|
||||||
|
padding-left: 2%;
|
||||||
|
}
|
||||||
|
.calendars-cmd-current, .calendars-cmd-today {
|
||||||
|
float: left;
|
||||||
|
width: 35%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.calendars-cmd-next, .calendars-cmd-nextJump, .calendars-cmd-close {
|
||||||
|
float: right;
|
||||||
|
padding-right: 2%;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.calendars-rtl .calendars-cmd-prev, .calendars-rtl .calendars-cmd-prevJump,
|
||||||
|
.calendars-rtl .calendars-cmd-clear {
|
||||||
|
float: right;
|
||||||
|
padding-left: 0%;
|
||||||
|
padding-right: 2%;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.calendars-rtl .calendars-cmd-current, .calendars-rtl .calendars-cmd-today {
|
||||||
|
float: right;
|
||||||
|
}
|
||||||
|
.calendars-rtl .calendars-cmd-next, .calendars-rtl .calendars-cmd-nextJump,
|
||||||
|
.calendars-rtl .calendars-cmd-close {
|
||||||
|
float: left;
|
||||||
|
padding-left: 2%;
|
||||||
|
padding-right: 0%;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.calendars-month-nav {
|
||||||
|
float: left;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.calendars-month-nav div {
|
||||||
|
float: left;
|
||||||
|
width: 12.5%;
|
||||||
|
margin: 1%;
|
||||||
|
padding: 1%;
|
||||||
|
}
|
||||||
|
.calendars-month-nav span {
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
.calendars-month-row {
|
||||||
|
clear: left;
|
||||||
|
}
|
||||||
|
.calendars-month {
|
||||||
|
float: left;
|
||||||
|
width: 15em;
|
||||||
|
border: 1px solid #5c9ccc;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.calendars-month-header, .calendars-month-header select, .calendars-month-header input {
|
||||||
|
height: 1.5em;
|
||||||
|
background-color: #5c9ccc;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.calendars-month-header select, .calendars-month-header input {
|
||||||
|
height: 1.4em;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
.calendars-month-header input {
|
||||||
|
position: absolute;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.calendars-month table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
.calendars-month thead {
|
||||||
|
border-bottom: 1px solid #aaa;
|
||||||
|
}
|
||||||
|
.calendars-month th, .calendars-month td {
|
||||||
|
margin: 0em;
|
||||||
|
padding: 0em;
|
||||||
|
font-weight: normal;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.calendars-month th {
|
||||||
|
border: 1px solid #fff;
|
||||||
|
border-bottom: 1px solid #c5dbec;
|
||||||
|
}
|
||||||
|
.calendars-month td {
|
||||||
|
border: 1px solid #c5dbec;
|
||||||
|
}
|
||||||
|
.calendars-month td.calendars-week * {
|
||||||
|
background-color: #d0e5f5;
|
||||||
|
color: #222;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
.calendars-month a {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.125em 0em;
|
||||||
|
background-color: #dfeffc;
|
||||||
|
color: #000;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.calendars-month span {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.125em 0em;
|
||||||
|
}
|
||||||
|
.calendars-month td span {
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
.calendars-month td .calendars-other-month {
|
||||||
|
background-color: #fff;
|
||||||
|
}
|
||||||
|
.calendars-month td .calendars-today {
|
||||||
|
background-color: #fad42e;
|
||||||
|
}
|
||||||
|
.calendars-month td .calendars-highlight {
|
||||||
|
background-color: #79b7e7;
|
||||||
|
}
|
||||||
|
.calendars-month td .calendars-selected {
|
||||||
|
background-color: #4297d7;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.calendars-status {
|
||||||
|
clear: both;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.calendars-clear-fix {
|
||||||
|
clear: both;
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
/* Smoothness styling for jQuery Calendars Picker v2.0.0. */
|
||||||
|
.calendars {
|
||||||
|
background-color: #fff;
|
||||||
|
color: #222;
|
||||||
|
border: 1px solid #aaa;
|
||||||
|
-moz-border-radius: 0.25em;
|
||||||
|
-webkit-border-radius: 0.25em;
|
||||||
|
border-radius: 0.25em;
|
||||||
|
font-family: Arial,Helvetica,Sans-serif;
|
||||||
|
font-size: 90%;
|
||||||
|
}
|
||||||
|
.calendars-rtl {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
.calendars-popup {
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
.calendars-disable {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 100;
|
||||||
|
background-color: white;
|
||||||
|
opacity: 0.5;
|
||||||
|
filter: alpha(opacity=50);
|
||||||
|
}
|
||||||
|
.calendars a {
|
||||||
|
color: #222;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.calendars a.calendars-disabled {
|
||||||
|
color: #888;
|
||||||
|
cursor: auto;
|
||||||
|
}
|
||||||
|
.calendars button {
|
||||||
|
margin: 0.25em;
|
||||||
|
padding: 0.125em 0em;
|
||||||
|
background-color: #fcc;
|
||||||
|
border: none;
|
||||||
|
-moz-border-radius: 0.25em;
|
||||||
|
-webkit-border-radius: 0.25em;
|
||||||
|
border-radius: 0.25em;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.calendars-nav, .calendars-ctrl {
|
||||||
|
float: left;
|
||||||
|
width: 100%;
|
||||||
|
background-color: #fff;
|
||||||
|
font-size: 90%;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.calendars-ctrl {
|
||||||
|
background-color: #fee6e3;
|
||||||
|
}
|
||||||
|
.calendars-cmd {
|
||||||
|
width: 30%;
|
||||||
|
}
|
||||||
|
.calendars-cmd:hover {
|
||||||
|
background-color: #e0e0e0;
|
||||||
|
}
|
||||||
|
.calendars-ctrl .calendars-cmd:hover {
|
||||||
|
background-color: #f08080;
|
||||||
|
}
|
||||||
|
.calendars-cmd-prevJump, .calendars-cmd-nextJump {
|
||||||
|
width: 8%;
|
||||||
|
}
|
||||||
|
a.calendars-cmd {
|
||||||
|
height: 1.5em;
|
||||||
|
}
|
||||||
|
button.calendars-cmd {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.calendars-cmd-prev, .calendars-cmd-prevJump, .calendars-cmd-clear {
|
||||||
|
float: left;
|
||||||
|
padding-left: 2%;
|
||||||
|
}
|
||||||
|
.calendars-cmd-current, .calendars-cmd-today {
|
||||||
|
float: left;
|
||||||
|
width: 35%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.calendars-cmd-next, .calendars-cmd-nextJump, .calendars-cmd-close {
|
||||||
|
float: right;
|
||||||
|
padding-right: 2%;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.calendars-rtl .calendars-cmd-prev, .calendars-rtl .calendars-cmd-prevJump,
|
||||||
|
.calendars-rtl .calendars-cmd-clear {
|
||||||
|
float: right;
|
||||||
|
padding-left: 0%;
|
||||||
|
padding-right: 2%;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.calendars-rtl .calendars-cmd-current, .calendars-rtl .calendars-cmd-today {
|
||||||
|
float: right;
|
||||||
|
}
|
||||||
|
.calendars-rtl .calendars-cmd-next, .calendars-rtl .calendars-cmd-nextJump,
|
||||||
|
.calendars-rtl .calendars-cmd-close {
|
||||||
|
float: left;
|
||||||
|
padding-left: 2%;
|
||||||
|
padding-right: 0%;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.calendars-month-nav {
|
||||||
|
float: left;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.calendars-month-nav div {
|
||||||
|
float: left;
|
||||||
|
width: 12.5%;
|
||||||
|
margin: 1%;
|
||||||
|
padding: 1%;
|
||||||
|
}
|
||||||
|
.calendars-month-nav span {
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
.calendars-month-row {
|
||||||
|
clear: left;
|
||||||
|
}
|
||||||
|
.calendars-month {
|
||||||
|
float: left;
|
||||||
|
width: 15em;
|
||||||
|
border: 1px solid #aaa;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.calendars-month-header, .calendars-month-header select, .calendars-month-header input {
|
||||||
|
height: 1.5em;
|
||||||
|
background-color: #e0e0e0;
|
||||||
|
color: #222;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.calendars-month-header select, .calendars-month-header input {
|
||||||
|
height: 1.4em;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
.calendars-month-header input {
|
||||||
|
position: absolute;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.calendars-month table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
.calendars-month thead {
|
||||||
|
border-bottom: 1px solid #aaa;
|
||||||
|
}
|
||||||
|
.calendars-month th, .calendars-month td {
|
||||||
|
margin: 0em;
|
||||||
|
padding: 0em;
|
||||||
|
font-weight: normal;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.calendars-month thead tr {
|
||||||
|
border: 1px solid #aaa;
|
||||||
|
}
|
||||||
|
.calendars-month td {
|
||||||
|
background-color: #eee;
|
||||||
|
border: 1px solid #aaa;
|
||||||
|
}
|
||||||
|
.calendars-month td.calendars-week * {
|
||||||
|
background-color: #e0e0e0;
|
||||||
|
color: #222;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
.calendars-month a {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.125em 0em;
|
||||||
|
background-color: #eee;
|
||||||
|
color: #000;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.calendars-month span {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.125em 0em;
|
||||||
|
}
|
||||||
|
.calendars-month td span {
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
.calendars-month td .calendars-other-month {
|
||||||
|
background-color: #fff;
|
||||||
|
}
|
||||||
|
.calendars-month td .calendars-weekend {
|
||||||
|
background-color: #ddd;
|
||||||
|
}
|
||||||
|
.calendars-month td .calendars-today {
|
||||||
|
background-color: #fbf9ee;
|
||||||
|
}
|
||||||
|
.calendars-month td .calendars-highlight {
|
||||||
|
background-color: #dadada;
|
||||||
|
}
|
||||||
|
.calendars-month td .calendars-selected {
|
||||||
|
background-color: #fcc;
|
||||||
|
}
|
||||||
|
.calendars-status {
|
||||||
|
clear: both;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.calendars-clear-fix {
|
||||||
|
clear: both;
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/* ThemeRoller Blacktie override style sheet for jQuery Calendars Picker v2.0.0. */
|
||||||
|
@import "ui.calendars.picker.css";
|
||||||
|
|
||||||
|
.ui-widget-header a,
|
||||||
|
.ui-widget-header select {
|
||||||
|
color: #eeeeee; /* Set (.ui-widget-header a) colour from theme here */
|
||||||
|
}
|
||||||
|
.ui-widget-header a:hover {
|
||||||
|
background-color: #1c1c1c; /* Set (.ui-state-hover) colours from theme here */
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
.ui-widget-header select,
|
||||||
|
.ui-widget-header option {
|
||||||
|
background-color: #333333; /* Set (.ui-widget-header) background colour from theme here */
|
||||||
|
}
|
||||||
|
.ui-state-highlight a {
|
||||||
|
color: #363636; /* Set (.ui-state-highlight) colour from theme here */
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/* ThemeRoller Blitzer override style sheet for jQuery Calendars Picker v2.0.0. */
|
||||||
|
@import "ui.calendars.picker.css";
|
||||||
|
|
||||||
|
.ui-widget-header a,
|
||||||
|
.ui-widget-header select {
|
||||||
|
color: #ffffff; /* Set (.ui-widget-header a) colour from theme here */
|
||||||
|
}
|
||||||
|
.ui-widget-header a:hover {
|
||||||
|
background-color: #f6f6f6; /* Set (.ui-state-hover) colours from theme here */
|
||||||
|
color: #111111;
|
||||||
|
}
|
||||||
|
.ui-widget-header select,
|
||||||
|
.ui-widget-header option {
|
||||||
|
background-color: #cc0000; /* Set (.ui-widget-header) background colour from theme here */
|
||||||
|
}
|
||||||
|
.ui-state-highlight a {
|
||||||
|
color: #555555; /* Set (.ui-state-highlight) colour from theme here */
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/* ThemeRoller Cupertino override style sheet for jQuery Calendars Picker v2.0.0. */
|
||||||
|
@import "ui.calendars.picker.css";
|
||||||
|
|
||||||
|
.ui-widget-header a,
|
||||||
|
.ui-widget-header select {
|
||||||
|
color: #222222; /* Set (.ui-widget-header a) colour from theme here */
|
||||||
|
}
|
||||||
|
.ui-widget-header a:hover {
|
||||||
|
background-color: #f0f0f0; /* Set (.ui-state-hover) colours from theme here */
|
||||||
|
color: #0b5b98;
|
||||||
|
}
|
||||||
|
.ui-widget-header select,
|
||||||
|
.ui-widget-header option {
|
||||||
|
background-color: #e7eef3; /* Set (.ui-widget-header) background colour from theme here */
|
||||||
|
}
|
||||||
|
.ui-state-highlight a {
|
||||||
|
color: #363636; /* Set (.ui-state-highlight) colour from theme here */
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/* ThemeRoller Dark Hive override style sheet for jQuery Calendars Picker v2.0.0. */
|
||||||
|
@import "ui.calendars.picker.css";
|
||||||
|
|
||||||
|
.ui-widget-header a,
|
||||||
|
.ui-widget-header select {
|
||||||
|
color: #ffffff; /* Set (.ui-widget-header a) colour from theme here */
|
||||||
|
}
|
||||||
|
.ui-widget-header a:hover {
|
||||||
|
background-color: #003147; /* Set (.ui-state-hover) colours from theme here */
|
||||||
|
color: #ffffff;;
|
||||||
|
}
|
||||||
|
.ui-widget-header select,
|
||||||
|
.ui-widget-header option {
|
||||||
|
background-color: #444444; /* Set (.ui-widget-header) background colour from theme here */
|
||||||
|
}
|
||||||
|
.ui-state-highlight a {
|
||||||
|
color: #eeeeee; /* Set (.ui-state-highlight) colour from theme here */
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/* ThemeRoller DotLuv override style sheet for jQuery Calendars Picker v2.0.0. */
|
||||||
|
@import "ui.calendars.picker.css";
|
||||||
|
|
||||||
|
.ui-widget-header a,
|
||||||
|
.ui-widget-header select {
|
||||||
|
color: #f6f6f6; /* Set (.ui-widget-header a) colour from theme here */
|
||||||
|
}
|
||||||
|
.ui-widget-header a:hover {
|
||||||
|
background-color: #00498f; /* Set (.ui-state-hover) colours from theme here */
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
.ui-widget-header select,
|
||||||
|
.ui-widget-header option {
|
||||||
|
background-color: #0b3e6f; /* Set (.ui-widget-header) background colour from theme here */
|
||||||
|
}
|
||||||
|
.ui-state-highlight a {
|
||||||
|
color: #ffffff; /* Set (.ui-state-highlight) colour from theme here */
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/* ThemeRoller Eggplant override style sheet for jQuery Calendars Picker v2.0.0. */
|
||||||
|
@import "ui.calendars.picker.css";
|
||||||
|
|
||||||
|
.ui-widget-header a,
|
||||||
|
.ui-widget-header select {
|
||||||
|
color: #ffffff; /* Set (.ui-widget-header a) colour from theme here */
|
||||||
|
}
|
||||||
|
.ui-widget-header a:hover {
|
||||||
|
background-color: #eae6ea; /* Set (.ui-state-hover) colours from theme here */
|
||||||
|
color: #734d99;
|
||||||
|
}
|
||||||
|
.ui-widget-header select,
|
||||||
|
.ui-widget-header option {
|
||||||
|
background-color: #30273a; /* Set (.ui-widget-header) background colour from theme here */
|
||||||
|
}
|
||||||
|
.ui-state-highlight a {
|
||||||
|
color: #fafafa; /* Set (.ui-state-highlight) colour from theme here */
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/* ThemeRoller ExciteBike override style sheet for jQuery dCalendars Picker v2.0.0. */
|
||||||
|
@import "ui.calendars.picker.css";
|
||||||
|
|
||||||
|
.ui-widget-header a,
|
||||||
|
.ui-widget-header select {
|
||||||
|
color: #e69700; /* Set (.ui-widget-header a) colour from theme here */
|
||||||
|
}
|
||||||
|
.ui-widget-header a:hover {
|
||||||
|
background-color: #2293f7; /* Set (.ui-state-hover) colours from theme here */
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
.ui-widget-header select,
|
||||||
|
.ui-widget-header option {
|
||||||
|
background-color: #f9f9f9; /* Set (.ui-widget-header) background colour from theme here */
|
||||||
|
}
|
||||||
|
.ui-state-highlight a {
|
||||||
|
color: #333333; /* Set (.ui-state-highlight) colour from theme here */
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/* ThemeRoller Flick override style sheet for jQuery Calendars Picker v2.0.0. */
|
||||||
|
@import "ui.calendars.picker.css";
|
||||||
|
|
||||||
|
.ui-widget-header a,
|
||||||
|
.ui-widget-header select {
|
||||||
|
color: #444444; /* Set (.ui-widget-header a) colour from theme here */
|
||||||
|
}
|
||||||
|
.ui-widget-header a:hover {
|
||||||
|
background-color: #0073ea; /* Set (.ui-state-hover) colours from theme here */
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
.ui-widget-header select,
|
||||||
|
.ui-widget-header option {
|
||||||
|
background-color: #dddddd; /* Set (.ui-widget-header) background colour from theme here */
|
||||||
|
}
|
||||||
|
.ui-state-highlight a {
|
||||||
|
color: #444444; /* Set (.ui-state-highlight) colour from theme here */
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/* ThemeRoller HotSneaks override style sheet for jQuery Calendars Picker v2.0.0. */
|
||||||
|
@import "ui.calendars.picker.css";
|
||||||
|
|
||||||
|
.ui-widget-header a,
|
||||||
|
.ui-widget-header select {
|
||||||
|
color: #e1e463; /* Set (.ui-widget-header a) colour from theme here */
|
||||||
|
}
|
||||||
|
.ui-widget-header a:hover {
|
||||||
|
background-color: #ccd232; /* Set (.ui-state-hover) colours from theme here */
|
||||||
|
color: #212121;
|
||||||
|
}
|
||||||
|
.ui-widget-header select,
|
||||||
|
.ui-widget-header option {
|
||||||
|
background-color: #35414f; /* Set (.ui-widget-header) background colour from theme here */
|
||||||
|
}
|
||||||
|
.ui-state-highlight a {
|
||||||
|
color: #363636; /* Set (.ui-state-highlight) colour from theme here */
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
/* ThemeRoller override style sheet for jQuery Calendars Picker v2.0.0. */
|
||||||
|
.ui-datepicker {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
#ui-datepicker-div,
|
||||||
|
.ui-datepicker-inline {
|
||||||
|
width: 17em;
|
||||||
|
font-size: 75%;
|
||||||
|
}
|
||||||
|
#ui-datepicker-div {
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
.ui-datepicker-inline {
|
||||||
|
float: left;
|
||||||
|
}
|
||||||
|
.ui-datepicker-rtl {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
#ui-datepicker-div a,
|
||||||
|
.ui-datepicker-inline a {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.ui-datepicker-prompt {
|
||||||
|
height: 1.5em;
|
||||||
|
padding-top: 0.25em;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
button.ui-datepicker-cmd {
|
||||||
|
height: 2em;
|
||||||
|
}
|
||||||
|
.ui-datepicker-cmd-clear {
|
||||||
|
float: left;
|
||||||
|
margin-left: 0.25em;
|
||||||
|
}
|
||||||
|
.ui-datepicker-cmd-close {
|
||||||
|
float: right;
|
||||||
|
margin-right: 0.25em;
|
||||||
|
}
|
||||||
|
.ui-datepicker-cmd-prev {
|
||||||
|
position: static;
|
||||||
|
float: left;
|
||||||
|
width: 30%;
|
||||||
|
height: auto;
|
||||||
|
margin-left: 1%;
|
||||||
|
}
|
||||||
|
.ui-datepicker-cmd-next {
|
||||||
|
position: static;
|
||||||
|
float: right;
|
||||||
|
width: 30%;
|
||||||
|
height: auto;
|
||||||
|
margin-right: 1%;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.ui-datepicker-cmd-current,
|
||||||
|
.ui-datepicker-cmd-today {
|
||||||
|
float: left;
|
||||||
|
width: 37%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.ui-datepicker-month-nav {
|
||||||
|
float: left;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.ui-datepicker-month-nav div {
|
||||||
|
float: left;
|
||||||
|
width: 12.5%;
|
||||||
|
margin: 1%;
|
||||||
|
padding: 1%;
|
||||||
|
}
|
||||||
|
.ui-datepicker-month-nav span {
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
.ui-datepicker-row-break {
|
||||||
|
width: 100%;
|
||||||
|
font-size: 100%;
|
||||||
|
}
|
||||||
|
.ui-datepicker-group {
|
||||||
|
float: left;
|
||||||
|
width: 17em;
|
||||||
|
}
|
||||||
|
.ui-datepicker-group .ui-datepicker-header {
|
||||||
|
height: 1.5em;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.ui-datepicker select,
|
||||||
|
.ui-datepicker-inline select {
|
||||||
|
width: auto;
|
||||||
|
height: 1.66em;
|
||||||
|
border: none;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.ui-datepicker th {
|
||||||
|
padding: 0.5em 0.3em;
|
||||||
|
}
|
||||||
|
.ui-datepicker td,
|
||||||
|
.ui-datepicker td a,
|
||||||
|
.ui-datepicker td span {
|
||||||
|
border: 1px solid transparent;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.ui-datepicker-status {
|
||||||
|
padding: 0.25em 0em;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.ui-datepicker .ui-helper-clearfix {
|
||||||
|
clear: both;
|
||||||
|
}
|
||||||
@@ -0,0 +1,457 @@
|
|||||||
|
import $ from "@/shared/common/jquery-global";
|
||||||
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
|
||||||
|
// CSS and plugins
|
||||||
|
import "@/shared/common/css/redmond.calendars.picker.css";
|
||||||
|
import "@/shared/common/css/ethiopian-date-picker-overrides.css";
|
||||||
|
import "@/shared/common/js/jquery.plugin.js";
|
||||||
|
import "@/shared/common/js/jquery.calendars.js";
|
||||||
|
import "@/shared/common/js/jquery.calendars.plus.js";
|
||||||
|
import "@/shared/common/js/jquery.calendars.picker.js";
|
||||||
|
import "@/shared/common/js/jquery.calendars.ethiopian.js";
|
||||||
|
import "@/shared/common/js/jquery.calendars.ethiopian-am.js";
|
||||||
|
|
||||||
|
interface CalendarsDate {
|
||||||
|
toString(): string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CalendarsDateFactory {
|
||||||
|
toJSDate(): Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CalendarsInstance {
|
||||||
|
newDate: (year: number, month: number, day: number) => CalendarsDateFactory;
|
||||||
|
fromJSDate: (jsDate: Date) => unknown;
|
||||||
|
formatDate: (
|
||||||
|
format: string,
|
||||||
|
date: unknown,
|
||||||
|
settings?: { localNumbers?: boolean },
|
||||||
|
) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CalendarsApi {
|
||||||
|
instance: (calendarType: string, language: string) => CalendarsInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CalendarsPickerOptions {
|
||||||
|
calendar: CalendarsInstance;
|
||||||
|
dateFormat: string;
|
||||||
|
onSelect: (dates: CalendarsDate[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type CalendarsPickerElement = JQuery<HTMLElement> & {
|
||||||
|
calendarsPicker: (...args: unknown[]) => unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface JQueryStatic {
|
||||||
|
calendars: CalendarsApi;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EthiopianDatePickerModalProps {
|
||||||
|
value?: string | Date;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ETHIOPIAN_UTC_OFFSET_HOURS = 3;
|
||||||
|
|
||||||
|
const toEthiopianTimeInput = (date: Date): string => {
|
||||||
|
if (!(date instanceof Date) || isNaN(date.getTime())) {
|
||||||
|
return "00:00";
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalMinutesUtc = date.getUTCHours() * 60 + date.getUTCMinutes();
|
||||||
|
const totalMinutesEthiopian =
|
||||||
|
(totalMinutesUtc + ETHIOPIAN_UTC_OFFSET_HOURS * 60 + 1440) % 1440;
|
||||||
|
|
||||||
|
const hh = Math.floor(totalMinutesEthiopian / 60)
|
||||||
|
.toString()
|
||||||
|
.padStart(2, "0");
|
||||||
|
const mm = (totalMinutesEthiopian % 60).toString().padStart(2, "0");
|
||||||
|
|
||||||
|
return `${hh}:${mm}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCurrentEthiopianTimeInput = (): string => {
|
||||||
|
return toEthiopianTimeInput(new Date());
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ethiopian 12-hour clock parts. The Ethiopian day starts at 6:00 EAT, so EAT
|
||||||
|
* 6:00 AM is clock 12 (day) and EAT 6:00 PM is clock 12 (night).
|
||||||
|
*
|
||||||
|
* `period` is purely a display concern — the stored value is always derived
|
||||||
|
* from the EAT 24-hour time, so the backend payload is unaffected.
|
||||||
|
*/
|
||||||
|
type EthiopianClockPeriod = "day" | "night";
|
||||||
|
|
||||||
|
interface EthiopianClockParts {
|
||||||
|
hour: number; // 1-12
|
||||||
|
minute: number; // 0-59
|
||||||
|
period: EthiopianClockPeriod;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ETHIOPIAN_HOUR_OPTIONS = Array.from({ length: 12 }, (_, i) => i + 1);
|
||||||
|
|
||||||
|
// EAT 24h "HH:mm" -> Ethiopian 12h clock parts (day starts at 6:00 EAT).
|
||||||
|
const eatTimeToEthiopianClock = (eatTime: string): EthiopianClockParts => {
|
||||||
|
const [hhRaw, mmRaw] = (eatTime || "").split(":");
|
||||||
|
const parsedHour = Number(hhRaw);
|
||||||
|
const parsedMinute = Number(mmRaw);
|
||||||
|
const eatHour = Number.isFinite(parsedHour)
|
||||||
|
? ((parsedHour % 24) + 24) % 24
|
||||||
|
: 0;
|
||||||
|
const minute = Number.isFinite(parsedMinute)
|
||||||
|
? ((parsedMinute % 60) + 60) % 60
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const hour = (eatHour + 6) % 12 || 12;
|
||||||
|
const period: EthiopianClockPeriod =
|
||||||
|
eatHour >= 6 && eatHour < 18 ? "day" : "night";
|
||||||
|
|
||||||
|
return { hour, minute, period };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ethiopian 12h clock parts -> EAT 24h "HH:mm".
|
||||||
|
const ethiopianClockToEatTime = ({
|
||||||
|
hour,
|
||||||
|
minute,
|
||||||
|
period,
|
||||||
|
}: EthiopianClockParts): string => {
|
||||||
|
const base = (hour % 12) + 6; // day window: 6..17
|
||||||
|
const eatHour = (period === "night" ? base + 12 : base) % 24;
|
||||||
|
const hh = eatHour.toString().padStart(2, "0");
|
||||||
|
const mm = (((minute % 60) + 60) % 60).toString().padStart(2, "0");
|
||||||
|
return `${hh}:${mm}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeDigits = (value: string): string =>
|
||||||
|
value
|
||||||
|
.replace(/[٠-٩]/g, (digit) => String(digit.charCodeAt(0) - 1632))
|
||||||
|
.replace(/[۰-۹]/g, (digit) => String(digit.charCodeAt(0) - 1776));
|
||||||
|
|
||||||
|
const toEthiopianDateInput = (date: Date): string => {
|
||||||
|
if (
|
||||||
|
!(date instanceof Date) ||
|
||||||
|
Number.isNaN(date.getTime()) ||
|
||||||
|
!$ ||
|
||||||
|
!$.calendars
|
||||||
|
) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const calendar = $.calendars.instance("ethiopian", "am");
|
||||||
|
const ethiopianDate = calendar.fromJSDate(
|
||||||
|
new Date(date.getTime() + ETHIOPIAN_UTC_OFFSET_HOURS * 60 * 60 * 1000),
|
||||||
|
);
|
||||||
|
|
||||||
|
return calendar.formatDate("yyyy-mm-dd", ethiopianDate, {
|
||||||
|
localNumbers: false,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const EthiopianDatePickerModal: React.FC<EthiopianDatePickerModalProps> = ({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}) => {
|
||||||
|
const datePickerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const pickerElementRef = useRef<CalendarsPickerElement | null>(null);
|
||||||
|
const isSyncingRef = useRef(false);
|
||||||
|
const lastSyncedDateRef = useRef<string>("");
|
||||||
|
const onChangeRef = useRef(onChange);
|
||||||
|
const initialValueRef = useRef(value);
|
||||||
|
const [selectedDate, setSelectedDate] = useState<string>("");
|
||||||
|
const [selectedTime, setSelectedTime] = useState<string>(
|
||||||
|
getCurrentEthiopianTimeInput,
|
||||||
|
);
|
||||||
|
const selectedTimeRef = useRef(selectedTime);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onChangeRef.current = onChange;
|
||||||
|
}, [onChange]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
selectedTimeRef.current = selectedTime;
|
||||||
|
}, [selectedTime]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!(value instanceof Date) || Number.isNaN(value.getTime())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextDate = toEthiopianDateInput(value);
|
||||||
|
// selectedTime is stored as EAT 24h "HH:mm"; the Ethiopian clock shown in
|
||||||
|
// the picker is derived from it via eatTimeToEthiopianClock. Seed it with
|
||||||
|
// EAT 24h here so the picker (and the trigger placeholder) agree.
|
||||||
|
const nextTime = toEthiopianTimeInput(value);
|
||||||
|
|
||||||
|
setSelectedDate(nextDate);
|
||||||
|
setSelectedTime(nextTime);
|
||||||
|
|
||||||
|
if (
|
||||||
|
!pickerElementRef.current ||
|
||||||
|
!$ ||
|
||||||
|
!$.calendars ||
|
||||||
|
!nextDate ||
|
||||||
|
lastSyncedDateRef.current === nextDate
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const calendar = $.calendars.instance("ethiopian", "am");
|
||||||
|
const [year, month, day] = nextDate.split("-").map(Number);
|
||||||
|
if (
|
||||||
|
Number.isFinite(year) &&
|
||||||
|
Number.isFinite(month) &&
|
||||||
|
Number.isFinite(day)
|
||||||
|
) {
|
||||||
|
const ethDate = calendar.newDate(year, month, day);
|
||||||
|
isSyncingRef.current = true;
|
||||||
|
try {
|
||||||
|
pickerElementRef.current.calendarsPicker("setDate", ethDate);
|
||||||
|
lastSyncedDateRef.current = nextDate;
|
||||||
|
} finally {
|
||||||
|
isSyncingRef.current = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore third-party plugin errors when syncing selection
|
||||||
|
}
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!$ || !$.calendars || !datePickerRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pickerHost = datePickerRef.current;
|
||||||
|
const calendar = $.calendars.instance("ethiopian", "am");
|
||||||
|
const picker = $(pickerHost) as unknown as CalendarsPickerElement;
|
||||||
|
pickerElementRef.current = picker;
|
||||||
|
|
||||||
|
picker.calendarsPicker({
|
||||||
|
calendar,
|
||||||
|
dateFormat: "yyyy-mm-dd",
|
||||||
|
onSelect: (dates: CalendarsDate[]) => {
|
||||||
|
if (isSyncingRef.current) return;
|
||||||
|
if (!dates?.length) return;
|
||||||
|
|
||||||
|
const date = dates[0].toString();
|
||||||
|
lastSyncedDateRef.current = date;
|
||||||
|
setSelectedDate(date);
|
||||||
|
onChangeRef.current(`${date} ${selectedTimeRef.current}`);
|
||||||
|
},
|
||||||
|
} as CalendarsPickerOptions);
|
||||||
|
|
||||||
|
const initialValue = initialValueRef.current;
|
||||||
|
if (initialValue instanceof Date && !Number.isNaN(initialValue.getTime())) {
|
||||||
|
try {
|
||||||
|
const initialEth = toEthiopianDateInput(initialValue);
|
||||||
|
if (initialEth) {
|
||||||
|
const [y, m, d] = initialEth.split("-").map(Number);
|
||||||
|
if (Number.isFinite(y) && Number.isFinite(m) && Number.isFinite(d)) {
|
||||||
|
isSyncingRef.current = true;
|
||||||
|
try {
|
||||||
|
picker.calendarsPicker("setDate", calendar.newDate(y, m, d));
|
||||||
|
lastSyncedDateRef.current = initialEth;
|
||||||
|
} finally {
|
||||||
|
isSyncingRef.current = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore third-party plugin errors when seeding initial date
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
try {
|
||||||
|
picker.calendarsPicker("destroy");
|
||||||
|
} catch {
|
||||||
|
// ignore cleanup errors from third-party plugin
|
||||||
|
}
|
||||||
|
pickerElementRef.current = null;
|
||||||
|
lastSyncedDateRef.current = "";
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// selectedTime is always EAT 24h "HH:mm"; the Ethiopian clock is a display-only
|
||||||
|
// projection so the stored value (and backend payload) stays unchanged.
|
||||||
|
const ethiopianClock = eatTimeToEthiopianClock(selectedTime);
|
||||||
|
|
||||||
|
const applyEthiopianClock = (next: EthiopianClockParts) => {
|
||||||
|
const eatTime = ethiopianClockToEatTime(next);
|
||||||
|
setSelectedTime(eatTime);
|
||||||
|
|
||||||
|
if (selectedDate) {
|
||||||
|
onChange(`${selectedDate} ${eatTime}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleHourChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||||
|
const hour = Number(e.target.value);
|
||||||
|
applyEthiopianClock({
|
||||||
|
...ethiopianClock,
|
||||||
|
hour: Number.isFinite(hour) ? hour : ethiopianClock.hour,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMinuteChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const parsed = Number(e.target.value);
|
||||||
|
const minute = Number.isFinite(parsed)
|
||||||
|
? Math.max(0, Math.min(59, Math.trunc(parsed)))
|
||||||
|
: 0;
|
||||||
|
applyEthiopianClock({ ...ethiopianClock, minute });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePeriodChange = (period: EthiopianClockPeriod) => {
|
||||||
|
applyEthiopianClock({ ...ethiopianClock, period });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ethiopian-picker-surface w-full space-y-3">
|
||||||
|
<div
|
||||||
|
ref={datePickerRef}
|
||||||
|
className="ethiopian-picker-calendar w-full overflow-hidden rounded-md border border-gray-200 dark:border-gray-700"
|
||||||
|
/>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-xs font-medium text-gray-600 dark:text-gray-300">
|
||||||
|
ሰዓት
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<select
|
||||||
|
aria-label="ሰዓት"
|
||||||
|
className="h-10 flex-1 rounded-md border border-gray-300 bg-white px-2 text-sm text-gray-800 shadow-sm transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
|
||||||
|
value={ethiopianClock.hour}
|
||||||
|
onChange={handleHourChange}>
|
||||||
|
{ETHIOPIAN_HOUR_OPTIONS.map((h) => (
|
||||||
|
<option key={h} value={h}>
|
||||||
|
{h}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<span className="text-sm font-medium text-gray-600 dark:text-gray-300">
|
||||||
|
:
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={59}
|
||||||
|
aria-label="ደቂቃ"
|
||||||
|
className="h-10 w-16 rounded-md border border-gray-300 bg-white px-2 text-center text-sm text-gray-800 shadow-sm transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
|
||||||
|
value={ethiopianClock.minute.toString().padStart(2, "0")}
|
||||||
|
onChange={handleMinuteChange}
|
||||||
|
/>
|
||||||
|
<div className="flex overflow-hidden rounded-md border border-gray-300 dark:border-gray-600">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handlePeriodChange("day")}
|
||||||
|
className={cn(
|
||||||
|
"px-3 py-2 text-sm transition-colors",
|
||||||
|
ethiopianClock.period === "day"
|
||||||
|
? "bg-primary text-white"
|
||||||
|
: "bg-white text-gray-700 hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700",
|
||||||
|
)}>
|
||||||
|
ቀን
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handlePeriodChange("night")}
|
||||||
|
className={cn(
|
||||||
|
"px-3 py-2 text-sm transition-colors",
|
||||||
|
ethiopianClock.period === "night"
|
||||||
|
? "bg-primary text-white"
|
||||||
|
: "bg-white text-gray-700 hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700",
|
||||||
|
)}>
|
||||||
|
ሌሊት
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export function convertEthiopianToGregorian(ethiopianDateTime: string): Date {
|
||||||
|
if (!ethiopianDateTime || typeof ethiopianDateTime !== "string") {
|
||||||
|
return new Date(NaN);
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedDateTime = normalizeDigits(ethiopianDateTime.trim());
|
||||||
|
const [datePart, timePart = "00:00"] = normalizedDateTime.split(" ");
|
||||||
|
|
||||||
|
if (!datePart || !datePart.match(/^\d{4}-\d{2}-\d{2}$/)) {
|
||||||
|
return new Date(NaN);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cal = $.calendars.instance("ethiopian", "am");
|
||||||
|
const [year, month, day] = datePart.split("-").map(Number);
|
||||||
|
|
||||||
|
if (isNaN(year) || isNaN(month) || isNaN(day)) {
|
||||||
|
return new Date(NaN);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ethDate = cal.newDate(year, month, day);
|
||||||
|
const gregDate = ethDate.toJSDate();
|
||||||
|
|
||||||
|
const [hours, minutes] = timePart.split(":").map(Number);
|
||||||
|
|
||||||
|
if (
|
||||||
|
isNaN(hours) ||
|
||||||
|
isNaN(minutes) ||
|
||||||
|
hours < 0 ||
|
||||||
|
hours > 23 ||
|
||||||
|
minutes < 0 ||
|
||||||
|
minutes > 59
|
||||||
|
) {
|
||||||
|
return new Date(NaN);
|
||||||
|
}
|
||||||
|
|
||||||
|
const utcDate = new Date(
|
||||||
|
Date.UTC(
|
||||||
|
gregDate.getFullYear(),
|
||||||
|
gregDate.getMonth(),
|
||||||
|
gregDate.getDate(),
|
||||||
|
hours - ETHIOPIAN_UTC_OFFSET_HOURS,
|
||||||
|
minutes,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return utcDate;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
"[convertEthiopianToGregorian] Error during conversion:",
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
return new Date(NaN);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatEthiopianDate(ethiopianDate: string | Date): string {
|
||||||
|
if (!ethiopianDate) return "";
|
||||||
|
|
||||||
|
if (ethiopianDate instanceof Date) {
|
||||||
|
const day = ethiopianDate.getDate().toString().padStart(2, "0");
|
||||||
|
const month = (ethiopianDate.getMonth() + 1).toString().padStart(2, "0");
|
||||||
|
const year = ethiopianDate.getFullYear();
|
||||||
|
return `${day}/${month}/${year}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = ethiopianDate.split("-");
|
||||||
|
if (parts.length !== 3) return ethiopianDate;
|
||||||
|
|
||||||
|
const [year, month, day] = parts;
|
||||||
|
return `${day.padStart(2, "0")}/${month.padStart(2, "0")}/${year}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default EthiopianDatePickerModal;
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Control, FieldValues } from "react-hook-form";
|
||||||
|
import $ from "@/shared/common/jquery-global";
|
||||||
|
import { FormField, FormItem, FormLabel, FormMessage } from "../../ui/form";
|
||||||
|
import EthiopianDatePickerModal, {
|
||||||
|
convertEthiopianToGregorian,
|
||||||
|
} from "./AmharicDatePicker";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "../../ui/popover";
|
||||||
|
import { Button } from "../../ui/button";
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import { CalendarIcon } from "lucide-react";
|
||||||
|
import { Calendar } from "../../ui/calendar";
|
||||||
|
import { Input } from "../../ui/input";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
formatEthiopianClockTime,
|
||||||
|
utcToEthiopian,
|
||||||
|
} from "@/shared/common/utils/ethiopianTimeZone";
|
||||||
|
|
||||||
|
interface DateFieldProps {
|
||||||
|
name: "startDate" | "endDate";
|
||||||
|
label: string;
|
||||||
|
control: Control<FieldValues>;
|
||||||
|
isEthiopian: boolean;
|
||||||
|
minDate?: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EthiopianCalendarFormatter {
|
||||||
|
fromJSDate: (jsDate: Date) => unknown;
|
||||||
|
formatDate: (
|
||||||
|
format: string,
|
||||||
|
date: unknown,
|
||||||
|
settings?: { localNumbers?: boolean },
|
||||||
|
) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DateField: React.FC<DateFieldProps> = ({
|
||||||
|
name,
|
||||||
|
label,
|
||||||
|
control,
|
||||||
|
isEthiopian,
|
||||||
|
minDate,
|
||||||
|
}) => {
|
||||||
|
const [gregorianTimeFallback, setGregorianTimeFallback] = useState<string>(
|
||||||
|
() => format(new Date(), "HH:mm"),
|
||||||
|
);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name={name}
|
||||||
|
render={({ field }) => {
|
||||||
|
const getGregorianTimeValue = (): string => {
|
||||||
|
if (
|
||||||
|
field.value instanceof Date &&
|
||||||
|
!Number.isNaN(field.value.getTime())
|
||||||
|
) {
|
||||||
|
return format(field.value, "HH:mm");
|
||||||
|
}
|
||||||
|
return gregorianTimeFallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEthiopianChange = (ethiopianDate: string) => {
|
||||||
|
const gregorianDate = convertEthiopianToGregorian(ethiopianDate);
|
||||||
|
if (!Number.isNaN(gregorianDate.getTime())) {
|
||||||
|
field.onChange(gregorianDate);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDisplayText = (): string => {
|
||||||
|
if (isEthiopian) {
|
||||||
|
if (
|
||||||
|
field.value instanceof Date &&
|
||||||
|
!Number.isNaN(field.value.getTime())
|
||||||
|
) {
|
||||||
|
const ethiopianTime = utcToEthiopian(field.value);
|
||||||
|
const timeStr = formatEthiopianClockTime(field.value);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ethiopianCalendar = $.calendars.instance(
|
||||||
|
"ethiopian",
|
||||||
|
"am",
|
||||||
|
) as unknown as EthiopianCalendarFormatter;
|
||||||
|
const ethiopianDate =
|
||||||
|
ethiopianCalendar.fromJSDate(ethiopianTime);
|
||||||
|
const dateStr = ethiopianCalendar.formatDate(
|
||||||
|
"yyyy-mm-dd",
|
||||||
|
ethiopianDate,
|
||||||
|
{ localNumbers: true },
|
||||||
|
);
|
||||||
|
return `${dateStr} ${timeStr}`;
|
||||||
|
} catch {
|
||||||
|
const fallbackDate = format(ethiopianTime, "yyyy-MM-dd");
|
||||||
|
return `${fallbackDate} ${timeStr}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return t("delegation.pickDate");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
field.value instanceof Date &&
|
||||||
|
!Number.isNaN(field.value.getTime())
|
||||||
|
) {
|
||||||
|
const dateText = format(field.value, "PPP");
|
||||||
|
const timeText = format(field.value, "HH:mm");
|
||||||
|
return `${dateText} ${timeText}`;
|
||||||
|
}
|
||||||
|
return t("delegation.pickDate");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormItem className="flex flex-col gap-2">
|
||||||
|
<FormLabel className="text-base font-medium text-gray-700 dark:text-gray-200">
|
||||||
|
{label}
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
"group h-11 w-full justify-between rounded-md border border-gray-300 bg-white px-3 text-left text-base font-normal shadow-sm transition-colors hover:bg-gray-50 focus-visible:ring-2 focus-visible:ring-primary/30 dark:border-gray-600 dark:bg-gray-800 dark:hover:bg-gray-700 dark:text-gray-100",
|
||||||
|
!field.value && "text-gray-500 dark:text-gray-400",
|
||||||
|
)}>
|
||||||
|
<span className="truncate">{getDisplayText()}</span>
|
||||||
|
<CalendarIcon className="ml-3 h-4 w-4 shrink-0 text-gray-500 transition-colors group-hover:text-gray-700 dark:text-gray-400 dark:group-hover:text-gray-200" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
|
||||||
|
<PopoverContent
|
||||||
|
className="w-auto max-w-[95vw] overflow-x-auto rounded-lg border border-gray-200 bg-white p-3 shadow-xl dark:border-gray-700 dark:bg-gray-900"
|
||||||
|
side="bottom"
|
||||||
|
align="start"
|
||||||
|
sideOffset={8}
|
||||||
|
avoidCollisions={false}>
|
||||||
|
{isEthiopian ? (
|
||||||
|
<div className="flex min-w-[20rem] flex-col gap-2">
|
||||||
|
<EthiopianDatePickerModal
|
||||||
|
value={field.value}
|
||||||
|
onChange={handleEthiopianChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="min-w-[20rem] space-y-3">
|
||||||
|
<Calendar
|
||||||
|
mode="single"
|
||||||
|
selected={
|
||||||
|
field.value instanceof Date &&
|
||||||
|
!Number.isNaN(field.value.getTime())
|
||||||
|
? field.value
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
defaultMonth={
|
||||||
|
field.value instanceof Date &&
|
||||||
|
!Number.isNaN(field.value.getTime())
|
||||||
|
? field.value
|
||||||
|
: minDate instanceof Date &&
|
||||||
|
!Number.isNaN(minDate.getTime())
|
||||||
|
? minDate
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onSelect={(selectedDate) => {
|
||||||
|
if (!selectedDate) {
|
||||||
|
field.onChange(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [hours, minutes] = getGregorianTimeValue()
|
||||||
|
.split(":")
|
||||||
|
.map(Number);
|
||||||
|
const nextDate = new Date(selectedDate);
|
||||||
|
nextDate.setHours(hours || 0, minutes || 0, 0, 0);
|
||||||
|
field.onChange(nextDate);
|
||||||
|
}}
|
||||||
|
initialFocus
|
||||||
|
className="w-full p-2"
|
||||||
|
disabled={(date) => {
|
||||||
|
const startOfToday = new Date(
|
||||||
|
new Date().setHours(0, 0, 0, 0),
|
||||||
|
);
|
||||||
|
if (date < startOfToday) return true;
|
||||||
|
if (
|
||||||
|
minDate instanceof Date &&
|
||||||
|
!Number.isNaN(minDate.getTime())
|
||||||
|
) {
|
||||||
|
const startOfMin = new Date(minDate);
|
||||||
|
startOfMin.setHours(0, 0, 0, 0);
|
||||||
|
if (date < startOfMin) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="space-y-1 px-2 pb-1">
|
||||||
|
<p className="text-xs font-medium text-gray-600 dark:text-gray-300">
|
||||||
|
{t("common.Time", "Time")}
|
||||||
|
</p>
|
||||||
|
<Input
|
||||||
|
type="time"
|
||||||
|
value={getGregorianTimeValue()}
|
||||||
|
onChange={(event) => {
|
||||||
|
const newTime = event.target.value;
|
||||||
|
setGregorianTimeFallback(newTime);
|
||||||
|
|
||||||
|
if (
|
||||||
|
field.value instanceof Date &&
|
||||||
|
!Number.isNaN(field.value.getTime())
|
||||||
|
) {
|
||||||
|
const [hours, minutes] = newTime
|
||||||
|
.split(":")
|
||||||
|
.map(Number);
|
||||||
|
const nextDate = new Date(field.value);
|
||||||
|
nextDate.setHours(hours || 0, minutes || 0, 0, 0);
|
||||||
|
field.onChange(nextDate);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="h-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
|
<FormMessage className="text-sm" />
|
||||||
|
</FormItem>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DateField;
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "../../ui/form";
|
||||||
|
import EthiopianDatePickerModal, {
|
||||||
|
convertEthiopianToGregorian,
|
||||||
|
} from "./AmharicDatePicker";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "../../ui/popover";
|
||||||
|
import { Button } from "../../ui/button";
|
||||||
|
import { Input } from "../../ui/input";
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import { CalendarIcon, Clock } from "lucide-react";
|
||||||
|
import { Calendar } from "../../ui/calendar";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import $ from "@/shared/common/jquery-global";
|
||||||
|
import {
|
||||||
|
formatEthiopianClockTime,
|
||||||
|
utcToEthiopian,
|
||||||
|
} from "@/shared/common/utils/ethiopianTimeZone";
|
||||||
|
|
||||||
|
interface DateTimeFieldProps {
|
||||||
|
name: "startDate" | "endDate";
|
||||||
|
label: string;
|
||||||
|
control: any;
|
||||||
|
isEthiopian: boolean;
|
||||||
|
isEditing: boolean;
|
||||||
|
fieldValue?: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EthiopianCalendarFormatter {
|
||||||
|
fromJSDate: (jsDate: Date) => unknown;
|
||||||
|
formatDate: (
|
||||||
|
format: string,
|
||||||
|
date: unknown,
|
||||||
|
settings?: { localNumbers?: boolean },
|
||||||
|
) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DateTimeField: React.FC<DateTimeFieldProps> = ({
|
||||||
|
name,
|
||||||
|
label,
|
||||||
|
control,
|
||||||
|
isEthiopian,
|
||||||
|
isEditing,
|
||||||
|
fieldValue,
|
||||||
|
}) => {
|
||||||
|
const [ethiopianDisplay, setEthiopianDisplay] = useState<string | null>("");
|
||||||
|
const [showTimeInput, setShowTimeInput] = useState(false);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name={name}
|
||||||
|
render={({ field }) => {
|
||||||
|
const handleEthiopianChange = (ethiopianDate: string) => {
|
||||||
|
setEthiopianDisplay(ethiopianDate);
|
||||||
|
const gregorianDate = convertEthiopianToGregorian(ethiopianDate);
|
||||||
|
field.onChange(gregorianDate);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTimeChange = (hours: string, minutes: string) => {
|
||||||
|
if (field.value) {
|
||||||
|
const date = new Date(field.value);
|
||||||
|
date.setHours(parseInt(hours) || 0, parseInt(minutes) || 0, 0, 0);
|
||||||
|
field.onChange(date);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTimeString = () => {
|
||||||
|
if (!field.value) return "";
|
||||||
|
const date = new Date(field.value);
|
||||||
|
const hours = String(date.getHours()).padStart(2, "0");
|
||||||
|
const minutes = String(date.getMinutes()).padStart(2, "0");
|
||||||
|
return `${hours}:${minutes}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDateTimeDisplay = () => {
|
||||||
|
if (!field.value) return t("delegation.pickDate");
|
||||||
|
const date = new Date(field.value);
|
||||||
|
if (Number.isNaN(date.getTime())) return t("delegation.pickDate");
|
||||||
|
const timeStr = isEthiopian
|
||||||
|
? formatEthiopianClockTime(date)
|
||||||
|
: getTimeString();
|
||||||
|
|
||||||
|
const dateStr = (() => {
|
||||||
|
if (!isEthiopian) return format(date, "PPP");
|
||||||
|
if (ethiopianDisplay) return ethiopianDisplay.split(" ")[0];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ethiopianCalendar = $.calendars.instance(
|
||||||
|
"ethiopian",
|
||||||
|
"am",
|
||||||
|
) as unknown as EthiopianCalendarFormatter;
|
||||||
|
const ethiopianDate = ethiopianCalendar.fromJSDate(
|
||||||
|
utcToEthiopian(date),
|
||||||
|
);
|
||||||
|
return ethiopianCalendar.formatDate(
|
||||||
|
"yyyy-mm-dd",
|
||||||
|
ethiopianDate,
|
||||||
|
{ localNumbers: true },
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return format(utcToEthiopian(date), "yyyy-MM-dd");
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return `${dateStr} ${timeStr}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormItem className="flex flex-col gap-2">
|
||||||
|
<FormLabel className="text-base font-medium text-gray-700">
|
||||||
|
{label}
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<FormControl>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
"w-full justify-between px-3 py-2 text-left font-normal text-base",
|
||||||
|
!field.value && "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{getDateTimeDisplay()}
|
||||||
|
<CalendarIcon className="ml-2 h-4 w-4 text-gray-500" />
|
||||||
|
</Button>
|
||||||
|
</FormControl>
|
||||||
|
</PopoverTrigger>
|
||||||
|
|
||||||
|
<PopoverContent
|
||||||
|
className="w-auto max-w-[95vw] overflow-x-auto p-4"
|
||||||
|
side="bottom"
|
||||||
|
align="start"
|
||||||
|
avoidCollisions={false}
|
||||||
|
>
|
||||||
|
<div className="min-w-[20rem] space-y-4">
|
||||||
|
{/* Date Picker */}
|
||||||
|
{isEthiopian ? (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<FormControl>
|
||||||
|
<EthiopianDatePickerModal
|
||||||
|
value={field.value}
|
||||||
|
onChange={handleEthiopianChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Calendar
|
||||||
|
mode="single"
|
||||||
|
selected={field.value}
|
||||||
|
onSelect={field.onChange}
|
||||||
|
initialFocus
|
||||||
|
disabled={(date) =>
|
||||||
|
date < new Date(new Date().setHours(0, 0, 0, 0))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Time Picker — Ethiopian mode uses the Ethiopian-clock
|
||||||
|
picker inside EthiopianDatePickerModal above, so this
|
||||||
|
Gregorian-local time picker is only shown otherwise. */}
|
||||||
|
{!isEthiopian && field.value && (
|
||||||
|
<div className="border-t pt-4">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Clock className="h-4 w-4 text-gray-500" />
|
||||||
|
<label className="text-sm font-medium text-gray-700">
|
||||||
|
{t("delegation.time") || "Time"}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="text-xs text-gray-600 block mb-1">
|
||||||
|
{t("delegation.hours") || "Hours"}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="23"
|
||||||
|
value={String(
|
||||||
|
new Date(field.value).getHours(),
|
||||||
|
).padStart(2, "0")}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleTimeChange(
|
||||||
|
e.target.value,
|
||||||
|
String(
|
||||||
|
new Date(field.value).getMinutes(),
|
||||||
|
).padStart(2, "0"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="text-center text-sm"
|
||||||
|
placeholder="00"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-center pt-6">
|
||||||
|
:
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="text-xs text-gray-600 block mb-1">
|
||||||
|
{t("delegation.minutes") || "Minutes"}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="59"
|
||||||
|
value={String(
|
||||||
|
new Date(field.value).getMinutes(),
|
||||||
|
).padStart(2, "0")}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleTimeChange(
|
||||||
|
String(
|
||||||
|
new Date(field.value).getHours(),
|
||||||
|
).padStart(2, "0"),
|
||||||
|
e.target.value,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="text-center text-sm"
|
||||||
|
placeholder="00"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DateTimeField;
|
||||||
@@ -0,0 +1,806 @@
|
|||||||
|
import React, { JSX, useRef, useState } from "react";
|
||||||
|
import { Trash2, X, ChevronDown, Search } from "lucide-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "../../ui/form";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "../../ui/select";
|
||||||
|
import { MultiSelect } from "../../ui/multi-select";
|
||||||
|
import { Input } from "../../ui/input";
|
||||||
|
import { Button } from "../../ui/button";
|
||||||
|
import TreeView, { TreeViewItem } from "@/shared/common/ui/treeSelect";
|
||||||
|
|
||||||
|
import { Controller } from "react-hook-form";
|
||||||
|
import { SingleSelect } from "../../ui/single-select";
|
||||||
|
import { useLocalizedName } from "../../localizedName";
|
||||||
|
import i18next from "i18next";
|
||||||
|
import { getCheckedLeafNodes } from "@/record-management/components/userRecords/common/treeUtils";
|
||||||
|
|
||||||
|
type BaseSelectOption = {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
unitId?: string;
|
||||||
|
};
|
||||||
|
interface CustomSelectProps {
|
||||||
|
form: any; // your react-hook-form instance
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
placeholder: string;
|
||||||
|
options: { id: string; name: { en: string; am: string } }[];
|
||||||
|
localizedName: (name: { en: string; am: string }) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1️⃣ Form Select Field
|
||||||
|
export const FormSelectField = ({
|
||||||
|
control,
|
||||||
|
name,
|
||||||
|
label,
|
||||||
|
options,
|
||||||
|
placeholder,
|
||||||
|
className,
|
||||||
|
customLabel,
|
||||||
|
disabled,
|
||||||
|
onCustomChange,
|
||||||
|
}: {
|
||||||
|
control: any;
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
options: BaseSelectOption[];
|
||||||
|
placeholder?: string;
|
||||||
|
className?: string;
|
||||||
|
customLabel?: React.ReactNode;
|
||||||
|
disabled?: boolean;
|
||||||
|
onCustomChange?: (value: string) => void;
|
||||||
|
}) => (
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name={name}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className={className || "space-y-1"}>
|
||||||
|
{customLabel ? (
|
||||||
|
customLabel
|
||||||
|
) : (
|
||||||
|
<FormLabel>
|
||||||
|
{name === "headerId" ||
|
||||||
|
name === "footerId" ||
|
||||||
|
name === "recordType" ||
|
||||||
|
name === "initiationType" ||
|
||||||
|
name === "initiatedAs" ? (
|
||||||
|
<span>
|
||||||
|
{label} <span className="text-red-500 text-xl">*</span>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
label
|
||||||
|
)}
|
||||||
|
</FormLabel>
|
||||||
|
)}
|
||||||
|
<FormControl>
|
||||||
|
<Select
|
||||||
|
value={field.value ?? ""}
|
||||||
|
disabled={disabled}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
field.onChange(value);
|
||||||
|
onCustomChange?.(value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue placeholder={placeholder} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="w-full max-w-full sm:max-w-md z-[60] overflow-x-auto">
|
||||||
|
{options.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const FormSingleSelectField = ({
|
||||||
|
control,
|
||||||
|
name,
|
||||||
|
label,
|
||||||
|
options,
|
||||||
|
placeholder = "Select option",
|
||||||
|
onCustomChange,
|
||||||
|
}: {
|
||||||
|
control: any;
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
options: BaseSelectOption[];
|
||||||
|
placeholder?: string;
|
||||||
|
onCustomChange?: (selectedValue: string) => void;
|
||||||
|
}) => (
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name={name}
|
||||||
|
render={({ field }) => {
|
||||||
|
const handleChange = (value: string) => {
|
||||||
|
field.onChange(value);
|
||||||
|
if (onCustomChange) onCustomChange(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormItem className="space-y-1">
|
||||||
|
<FormLabel>{label}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<SingleSelect
|
||||||
|
options={options}
|
||||||
|
value={field.value || ""}
|
||||||
|
onValueChange={handleChange}
|
||||||
|
placeholder={placeholder}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
// 2️⃣ Multi Select Field
|
||||||
|
export const FormMultiSelectField = ({
|
||||||
|
control,
|
||||||
|
name,
|
||||||
|
label,
|
||||||
|
options,
|
||||||
|
placeholder = "Select options",
|
||||||
|
maxCount,
|
||||||
|
disabled,
|
||||||
|
onCustomChange,
|
||||||
|
}: {
|
||||||
|
control: any;
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
options: BaseSelectOption[];
|
||||||
|
placeholder?: string;
|
||||||
|
maxCount?: number;
|
||||||
|
disabled?: boolean;
|
||||||
|
onCustomChange?: (selectedValues: string[]) => void;
|
||||||
|
}) => (
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name={name}
|
||||||
|
render={({ field }) => {
|
||||||
|
const handleChange = (values: string[]) => {
|
||||||
|
field.onChange(values);
|
||||||
|
if (onCustomChange) onCustomChange(values);
|
||||||
|
};
|
||||||
|
|
||||||
|
const fieldValue = Array.isArray(field.value)
|
||||||
|
? field.value
|
||||||
|
: field.value
|
||||||
|
? [field.value]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormItem className="space-y-1">
|
||||||
|
<FormLabel>
|
||||||
|
{name === "toPositions" ||
|
||||||
|
name === "toEmployees" ||
|
||||||
|
name === "collaborators" ||
|
||||||
|
name === "receivingDepartments" ||
|
||||||
|
name === "assignedEmployees" ? (
|
||||||
|
<span>
|
||||||
|
{label} <span className="text-red-500 text-xl">*</span>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
label
|
||||||
|
)}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<MultiSelect
|
||||||
|
options={options}
|
||||||
|
value={fieldValue} // controlled value
|
||||||
|
onValueChange={handleChange} // controlled onChange
|
||||||
|
placeholder={placeholder}
|
||||||
|
maxCount={maxCount || 5}
|
||||||
|
disabled={disabled}
|
||||||
|
className="w-full max-w-full sm:max-w-md overflow-x-auto"
|
||||||
|
animation={0}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const FormTreeSelectField = ({
|
||||||
|
control,
|
||||||
|
name,
|
||||||
|
label,
|
||||||
|
options,
|
||||||
|
handleCheckChange,
|
||||||
|
collapsible = false,
|
||||||
|
localizedName,
|
||||||
|
selectedCountResolver,
|
||||||
|
}: {
|
||||||
|
control: any;
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
options: TreeViewItem[];
|
||||||
|
handleCheckChange?:
|
||||||
|
| ((item: TreeViewItem | TreeViewItem[], checked: boolean) => void)
|
||||||
|
| undefined;
|
||||||
|
collapsible?: boolean;
|
||||||
|
localizedName: (
|
||||||
|
name?:
|
||||||
|
| {
|
||||||
|
am: string;
|
||||||
|
en: string;
|
||||||
|
}
|
||||||
|
| undefined,
|
||||||
|
) => string;
|
||||||
|
selectedCountResolver?: (value: unknown) => number;
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [isOpen, setIsOpen] = React.useState(!collapsible);
|
||||||
|
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Close dropdown on outside click
|
||||||
|
React.useEffect(() => {
|
||||||
|
function handleClickOutside(event: MouseEvent) {
|
||||||
|
if (
|
||||||
|
containerRef.current &&
|
||||||
|
!containerRef.current.contains(event.target as Node)
|
||||||
|
) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (collapsible) {
|
||||||
|
return (
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name={name}
|
||||||
|
rules={{ required: `${label} is required` }}
|
||||||
|
render={({ field }) => {
|
||||||
|
const selectedCount = selectedCountResolver
|
||||||
|
? selectedCountResolver(field.value)
|
||||||
|
: getCheckedLeafNodes(Array.isArray(field.value) ? field.value : [])
|
||||||
|
.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormItem
|
||||||
|
ref={containerRef}
|
||||||
|
className="sm:col-span-3 w-full min-w-0 max-w-full"
|
||||||
|
>
|
||||||
|
<FormLabel>
|
||||||
|
{name === "receivingUnitIds" ? (
|
||||||
|
<span>
|
||||||
|
{label} <span className="text-red-500 text-xl">*</span>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
label
|
||||||
|
)}
|
||||||
|
</FormLabel>
|
||||||
|
<div className="relative w-full min-w-0 max-w-full">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
className="h-auto min-h-10 w-full max-w-full justify-between gap-2 py-2"
|
||||||
|
>
|
||||||
|
<span className="block min-w-0 flex-1 truncate text-left">
|
||||||
|
{selectedCount > 0
|
||||||
|
? `${selectedCount} unit(s) selected`
|
||||||
|
: `${t("common.select")} ${label}`}
|
||||||
|
</span>
|
||||||
|
<ChevronDown
|
||||||
|
className={`h-4 w-4 shrink-0 transition-transform ${
|
||||||
|
isOpen ? "rotate-180" : ""
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div className="absolute left-0 right-0 z-50 mt-1 w-full min-w-0 max-w-full overflow-hidden rounded-md border bg-background p-2 shadow-md">
|
||||||
|
<TreeView
|
||||||
|
embedded
|
||||||
|
className="max-h-[55vh] overflow-y-auto sm:max-h-[450px]"
|
||||||
|
data={options}
|
||||||
|
showExpandAll={false}
|
||||||
|
showCheckboxes
|
||||||
|
onCheckChange={handleCheckChange}
|
||||||
|
localizedName={localizedName}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name={name}
|
||||||
|
rules={{ required: `${label} is required` }}
|
||||||
|
render={() => (
|
||||||
|
<FormItem className="sm:col-span-3 w-full min-w-0">
|
||||||
|
<FormLabel>{label}</FormLabel>
|
||||||
|
<TreeView
|
||||||
|
className="max-h-[55vh] overflow-y-auto sm:max-h-[450px]"
|
||||||
|
data={options}
|
||||||
|
showExpandAll={false}
|
||||||
|
showCheckboxes
|
||||||
|
onCheckChange={handleCheckChange}
|
||||||
|
localizedName={localizedName}
|
||||||
|
/>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 3️⃣ Addable List Field (string-based)
|
||||||
|
export const FormAddableList = ({
|
||||||
|
form,
|
||||||
|
name,
|
||||||
|
label,
|
||||||
|
placeholder = "",
|
||||||
|
valueAdapter,
|
||||||
|
}: {
|
||||||
|
form: any;
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
placeholder?: string;
|
||||||
|
valueAdapter?: {
|
||||||
|
getDisplayItems: (fieldValue: unknown) => string[];
|
||||||
|
onItemsChange: (items: string[], fieldValue: unknown) => unknown;
|
||||||
|
};
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [pendingValue, setPendingValue] = useState("");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name={name}
|
||||||
|
render={({ field }) => {
|
||||||
|
const items = valueAdapter
|
||||||
|
? valueAdapter.getDisplayItems(field.value)
|
||||||
|
: Array.isArray(field.value)
|
||||||
|
? field.value
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const handleAdd = () => {
|
||||||
|
const value = pendingValue.trim();
|
||||||
|
if (value && !items.includes(value)) {
|
||||||
|
const updatedItems = [...items, value];
|
||||||
|
field.onChange(
|
||||||
|
valueAdapter
|
||||||
|
? valueAdapter.onItemsChange(updatedItems, field.value)
|
||||||
|
: updatedItems,
|
||||||
|
);
|
||||||
|
setPendingValue("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (index: number) => {
|
||||||
|
const updatedItems = items.filter(
|
||||||
|
(_: string, i: number) => i !== index,
|
||||||
|
);
|
||||||
|
field.onChange(
|
||||||
|
valueAdapter
|
||||||
|
? valueAdapter.onItemsChange(updatedItems, field.value)
|
||||||
|
: updatedItems,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
handleAdd();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormItem className="space-y-2">
|
||||||
|
<FormLabel>{label}</FormLabel>
|
||||||
|
<div className="flex flex-col sm:flex-row gap-2">
|
||||||
|
<FormControl className="flex-1">
|
||||||
|
<Input
|
||||||
|
placeholder={placeholder}
|
||||||
|
value={pendingValue}
|
||||||
|
onChange={(e) => setPendingValue(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onBlur={handleAdd}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={handleAdd}
|
||||||
|
className="bg-primary-500 text-white hover:bg-primary-600"
|
||||||
|
>
|
||||||
|
{t("addRecord.Add")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{items.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1 mt-2">
|
||||||
|
{items.map((unit: string, index: number) => (
|
||||||
|
<div
|
||||||
|
key={`${unit}-${index}`}
|
||||||
|
className="flex items-center gap-1 px-2 py-1 bg-blue-50 text-blue-700 border border-blue-100 rounded-md"
|
||||||
|
>
|
||||||
|
<span className="text-sm truncate max-w-[200px]">
|
||||||
|
{unit}
|
||||||
|
</span>
|
||||||
|
<X
|
||||||
|
size={14}
|
||||||
|
className="cursor-pointer text-blue-700 hover:text-red-600 ml-1"
|
||||||
|
onClick={() => handleDelete(index)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
export const ClearableSelect = ({
|
||||||
|
form,
|
||||||
|
name,
|
||||||
|
label,
|
||||||
|
placeholder = "",
|
||||||
|
options,
|
||||||
|
localizedName,
|
||||||
|
}: {
|
||||||
|
form: any;
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
placeholder?: string;
|
||||||
|
options: { id: string; name: any }[];
|
||||||
|
localizedName: (name: any) => string;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<FormItem className="space-y-2">
|
||||||
|
<FormLabel>{label}</FormLabel>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name={name}
|
||||||
|
render={({ field }) => (
|
||||||
|
<>
|
||||||
|
{field.value ? (
|
||||||
|
// Selected value with X to clear
|
||||||
|
<div className="flex items-center justify-between border text-sm rounded-md px-3 py-2 bg-gray-50">
|
||||||
|
<span>
|
||||||
|
{localizedName(
|
||||||
|
options.find((o) => o.id === field.value)?.name,
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<X
|
||||||
|
size={17}
|
||||||
|
className="cursor-pointer text-blue-700 hover:text-red-600 ml-1"
|
||||||
|
onClick={() => field.onChange("")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
// Select dropdown
|
||||||
|
<Select
|
||||||
|
value={field.value}
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
defaultValue={field.value}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue placeholder={placeholder} />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent className="w-full max-w-full sm:max-w-md z-[60] overflow-x-auto">
|
||||||
|
{options?.map((item) => (
|
||||||
|
<SelectItem key={item.id} value={item.id}>
|
||||||
|
{localizedName(item.name)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
<FormMessage />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</FormItem>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CustomSelect = ({
|
||||||
|
form,
|
||||||
|
name,
|
||||||
|
label,
|
||||||
|
placeholder,
|
||||||
|
options,
|
||||||
|
localizedName,
|
||||||
|
}: CustomSelectProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-sm font-medium">{label}</label>
|
||||||
|
<Controller
|
||||||
|
control={form.control}
|
||||||
|
name={name}
|
||||||
|
render={({ field }) => {
|
||||||
|
const matchingOption = options.find((opt) => {
|
||||||
|
const english = opt.name?.en ?? "";
|
||||||
|
const amharic = opt.name?.am ?? "";
|
||||||
|
return (
|
||||||
|
field.value === opt.id ||
|
||||||
|
field.value === english ||
|
||||||
|
field.value === amharic
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Treat undefined, null, and empty string as no selection
|
||||||
|
const hasValue = field.value !== undefined && field.value !== null && field.value !== "";
|
||||||
|
const resolvedValue = matchingOption && hasValue
|
||||||
|
? matchingOption.id
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
value={resolvedValue}
|
||||||
|
onValueChange={(val) => {
|
||||||
|
if (val === "__clear__" || val === "") {
|
||||||
|
field.onChange("");
|
||||||
|
} else {
|
||||||
|
field.onChange(val);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue placeholder={placeholder} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{options.map((opt) => (
|
||||||
|
<SelectItem key={opt.id} value={opt.id}>
|
||||||
|
{localizedName(opt.name)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
<SelectItem value="__clear__">{t("common.Cancel")}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface TreeViewItemO {
|
||||||
|
id: string;
|
||||||
|
name: string | { en: string; am: string }; // allow localized or raw
|
||||||
|
value?: string;
|
||||||
|
children?: TreeViewItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SimpleTreeSelectProps {
|
||||||
|
label: string;
|
||||||
|
options: TreeViewItemO[];
|
||||||
|
value: string | null;
|
||||||
|
onChange: (id: string | null) => void;
|
||||||
|
collapsible?: boolean;
|
||||||
|
localizedName?: (name?: { en?: string; am?: string } | undefined) => string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SimpleTreeSelect = ({
|
||||||
|
label,
|
||||||
|
options,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
collapsible = false,
|
||||||
|
localizedName,
|
||||||
|
className,
|
||||||
|
}: SimpleTreeSelectProps) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(!collapsible);
|
||||||
|
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const toggleExpand = (id: string) => {
|
||||||
|
const newSet = new Set(expandedItems);
|
||||||
|
if (newSet.has(id)) newSet.delete(id);
|
||||||
|
else newSet.add(id);
|
||||||
|
setExpandedItems(newSet);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelect = (id: string) => {
|
||||||
|
onChange(id);
|
||||||
|
// Optional: close dropdown after selection
|
||||||
|
// setIsOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get full path for a selected item
|
||||||
|
const findSelectedPath = (
|
||||||
|
items: TreeViewItemO[],
|
||||||
|
parentPath: string[] = [],
|
||||||
|
): string | null => {
|
||||||
|
for (const item of items) {
|
||||||
|
const name =
|
||||||
|
typeof item.name === "string"
|
||||||
|
? item.name
|
||||||
|
: (localizedName?.(item.name) ?? item.name.en);
|
||||||
|
|
||||||
|
if (item.id === value) {
|
||||||
|
return [...parentPath, name].join(" / ");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.children && item.children.length > 0) {
|
||||||
|
const childPath = findSelectedPath(item.children, [
|
||||||
|
...parentPath,
|
||||||
|
name,
|
||||||
|
]);
|
||||||
|
if (childPath) return childPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderTree = (items: TreeViewItemO[]): JSX.Element[] => {
|
||||||
|
return items.flatMap((item) => {
|
||||||
|
const hasChildren = item.children && item.children.length > 0;
|
||||||
|
const isExpanded = expandedItems.has(item.id);
|
||||||
|
|
||||||
|
// Determine if any child is selected to highlight parent
|
||||||
|
const isParentSelected = hasChildren
|
||||||
|
? item.children!.some((child) => child.id === value)
|
||||||
|
: false;
|
||||||
|
|
||||||
|
// Skip single-unit org
|
||||||
|
if (hasChildren && item.children!.length === 1) {
|
||||||
|
return renderTree(item.children!);
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemLabel =
|
||||||
|
typeof item.name === "string"
|
||||||
|
? item.name
|
||||||
|
: (localizedName?.(item.name) ?? item.name.en);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={item.id} className="ml-4 mb-1">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
{hasChildren && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleExpand(item.id)}
|
||||||
|
className="w-4 h-4 flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<ChevronDown
|
||||||
|
className={`h-3 w-3 transition-transform ${
|
||||||
|
isExpanded ? "rotate-180" : ""
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<label className="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="unit-select"
|
||||||
|
checked={value === item.id || isParentSelected}
|
||||||
|
onChange={() => handleSelect(item.id)}
|
||||||
|
/>
|
||||||
|
<span className={isParentSelected ? "font-semibold" : ""}>
|
||||||
|
{itemLabel}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasChildren && isExpanded && (
|
||||||
|
<div className="ml-4">{renderTree(item.children!)}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedPathLabel = findSelectedPath(options);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className ?? "mb-4"}>
|
||||||
|
<label className="block font-semibold mb-1">{label}</label>
|
||||||
|
|
||||||
|
{collapsible && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
className="w-full max-w-lg justify-between mb-2"
|
||||||
|
>
|
||||||
|
<span className="truncate">
|
||||||
|
{selectedPathLabel ?? label}
|
||||||
|
</span>
|
||||||
|
<ChevronDown
|
||||||
|
className={`h-4 w-4 transition-transform ${
|
||||||
|
isOpen ? "rotate-180" : ""
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div className="border rounded-md p-2 bg-background max-h-96 overflow-y-auto max-w-lg">
|
||||||
|
{renderTree(options)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SelectedItemsChips = ({
|
||||||
|
items,
|
||||||
|
onRemove,
|
||||||
|
label,
|
||||||
|
localizedName,
|
||||||
|
}: {
|
||||||
|
items: TreeViewItem[];
|
||||||
|
onRemove: (value: string) => void;
|
||||||
|
label?: string;
|
||||||
|
localizedName: (
|
||||||
|
name?:
|
||||||
|
| {
|
||||||
|
am: string;
|
||||||
|
en: string;
|
||||||
|
}
|
||||||
|
| undefined,
|
||||||
|
) => string;
|
||||||
|
}) => {
|
||||||
|
if (!items?.length) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-2 sm:col-span-3">
|
||||||
|
{label && <p className="text-sm font-semibold mb-2">{label}</p>}
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{items.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className="flex items-center bg-blue-100 text-blue-800 px-3 py-1 rounded-full text-sm"
|
||||||
|
>
|
||||||
|
<span className="truncate max-w-[180px]">
|
||||||
|
{localizedName(item.name)}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ml-2 text-red-500 hover:text-red-700"
|
||||||
|
onClick={() => {
|
||||||
|
onRemove(item.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
After Width: | Height: | Size: 969 B |
|
After Width: | Height: | Size: 934 B |
|
After Width: | Height: | Size: 269 B |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,7 @@
|
|||||||
|
import jQuery from "jquery";
|
||||||
|
|
||||||
|
// Make jQuery global for legacy plugins
|
||||||
|
(window as any).jQuery = jQuery;
|
||||||
|
(window as any).$ = jQuery;
|
||||||
|
|
||||||
|
export default jQuery;
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import $ from "jquery";
|
||||||
|
|
||||||
|
(function ($: any) {
|
||||||
|
"use strict";
|
||||||
|
$.calendars.calendars.ethiopian.prototype.regionalOptions.am = {
|
||||||
|
name: "የኢትዮጵያ ዘመን አቆጣጠር",
|
||||||
|
epochs: ["BEE", "EE"],
|
||||||
|
monthNames: [
|
||||||
|
"መስከረም",
|
||||||
|
"ጥቅምት",
|
||||||
|
"ኅዳር",
|
||||||
|
"ታህሣሥ",
|
||||||
|
"ጥር",
|
||||||
|
"የካቲት",
|
||||||
|
"መጋቢት",
|
||||||
|
"ሚያዝያ",
|
||||||
|
"ግንቦት",
|
||||||
|
"ሰኔ",
|
||||||
|
"ሐምሌ",
|
||||||
|
"ነሐሴ",
|
||||||
|
"ጳጉሜ",
|
||||||
|
],
|
||||||
|
monthNamesShort: [
|
||||||
|
"መስከ",
|
||||||
|
"ጥቅም",
|
||||||
|
"ኅዳር",
|
||||||
|
"ታህሣ",
|
||||||
|
"ጥር",
|
||||||
|
"የካቲ",
|
||||||
|
"መጋቢ",
|
||||||
|
"ሚያዝ",
|
||||||
|
"ግንቦ",
|
||||||
|
"ሰኔ",
|
||||||
|
"ሐምሌ",
|
||||||
|
"ነሐሴ",
|
||||||
|
"ጳጉሜ",
|
||||||
|
],
|
||||||
|
dayNames: ["እሑድ", "ሰኞ", "ማክሰኞ", "ረቡዕ", "ሓሙስ", "ዓርብ", "ቅዳሜ"],
|
||||||
|
dayNamesShort: ["እሑድ", "ሰኞ", "ማክሰ", "ረቡዕ", "ሓሙስ", "ዓርብ", "ቅዳሜ"],
|
||||||
|
dayNamesMin: ["እሑ", "ሰኞ", "ማክ", "ረቡ", "ሐሙ", "ዓር", "ቅዳ"],
|
||||||
|
digits: null,
|
||||||
|
dateFormat: "dd/mm/yyyy",
|
||||||
|
firstDay: 0,
|
||||||
|
isRTL: false,
|
||||||
|
};
|
||||||
|
})(jQuery);
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
/* http://keith-wood.name/calendars.html
|
||||||
|
Ethiopian calendar for jQuery v2.1.0.
|
||||||
|
Written by Keith Wood (wood.keith{at}optusnet.com.au) February 2010.
|
||||||
|
Available under the MIT (http://keith-wood.name/licence.html) license.
|
||||||
|
Please attribute the author if you use it. */
|
||||||
|
|
||||||
|
(function($) { // Hide scope, no $ conflict
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
/** Implementation of the Ethiopian calendar.
|
||||||
|
See <a href="http://en.wikipedia.org/wiki/Ethiopian_calendar">http://en.wikipedia.org/wiki/Ethiopian_calendar</a>.
|
||||||
|
See also Calendrical Calculations: The Millennium Edition
|
||||||
|
(<a href="http://emr.cs.iit.edu/home/reingold/calendar-book/index.shtml">http://emr.cs.iit.edu/home/reingold/calendar-book/index.shtml</a>).
|
||||||
|
@class EthiopianCalendar
|
||||||
|
@param {string} [language=''] The language code (default English) for localisation. */
|
||||||
|
function EthiopianCalendar(language) {
|
||||||
|
this.local = this.regionalOptions[language || ''] || this.regionalOptions[''];
|
||||||
|
}
|
||||||
|
|
||||||
|
EthiopianCalendar.prototype = new $.calendars.baseCalendar();
|
||||||
|
|
||||||
|
$.extend(EthiopianCalendar.prototype, {
|
||||||
|
/** The calendar name.
|
||||||
|
@memberof EthiopianCalendar */
|
||||||
|
name: 'Ethiopian',
|
||||||
|
/** Julian date of start of Ethiopian epoch: 27 August 8 CE (Gregorian).
|
||||||
|
@memberof EthiopianCalendar */
|
||||||
|
jdEpoch: 1724220.5,
|
||||||
|
/** Days per month in a common year.
|
||||||
|
@memberof EthiopianCalendar */
|
||||||
|
daysPerMonth: [30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 5],
|
||||||
|
/** <code>true</code> if has a year zero, <code>false</code> if not.
|
||||||
|
@memberof EthiopianCalendar */
|
||||||
|
hasYearZero: false,
|
||||||
|
/** The minimum month number.
|
||||||
|
@memberof EthiopianCalendar */
|
||||||
|
minMonth: 1,
|
||||||
|
/** The first month in the year.
|
||||||
|
@memberof EthiopianCalendar */
|
||||||
|
firstMonth: 1,
|
||||||
|
/** The minimum day number.
|
||||||
|
@memberof EthiopianCalendar */
|
||||||
|
minDay: 1,
|
||||||
|
|
||||||
|
/** Localisations for the plugin.
|
||||||
|
Entries are objects indexed by the language code ('' being the default US/English).
|
||||||
|
Each object has the following attributes.
|
||||||
|
@memberof EthiopianCalendar
|
||||||
|
@property {string} name The calendar name.
|
||||||
|
@property {string[]} epochs The epoch names (before/after year 0).
|
||||||
|
@property {string[]} monthNames The long names of the months of the year.
|
||||||
|
@property {string[]} monthNamesShort The short names of the months of the year.
|
||||||
|
@property {string[]} dayNames The long names of the days of the week.
|
||||||
|
@property {string[]} dayNamesShort The short names of the days of the week.
|
||||||
|
@property {string[]} dayNamesMin The minimal names of the days of the week.
|
||||||
|
@property {string} dateFormat The date format for this calendar.
|
||||||
|
See the options on <a href="BaseCalendar.html#formatDate"><code>formatDate</code></a> for details.
|
||||||
|
@property {number} firstDay The number of the first day of the week, starting at 0.
|
||||||
|
@property {boolean} isRTL <code>true</code> if this localisation reads right-to-left. */
|
||||||
|
regionalOptions: { // Localisations
|
||||||
|
'': {
|
||||||
|
name: 'Ethiopian',
|
||||||
|
epochs: ['BEE', 'EE'],
|
||||||
|
monthNames: ['Meskerem', 'Tikemet', 'Hidar', 'Tahesas', 'Tir', 'Yekatit',
|
||||||
|
'Megabit', 'Miazia', 'Genbot', 'Sene', 'Hamle', 'Nehase', 'Pagume'],
|
||||||
|
monthNamesShort: ['Mes', 'Tik', 'Hid', 'Tah', 'Tir', 'Yek',
|
||||||
|
'Meg', 'Mia', 'Gen', 'Sen', 'Ham', 'Neh', 'Pag'],
|
||||||
|
dayNames: ['Ehud', 'Segno', 'Maksegno', 'Irob', 'Hamus', 'Arb', 'Kidame'],
|
||||||
|
dayNamesShort: ['Ehu', 'Seg', 'Mak', 'Iro', 'Ham', 'Arb', 'Kid'],
|
||||||
|
dayNamesMin: ['Eh', 'Se', 'Ma', 'Ir', 'Ha', 'Ar', 'Ki'],
|
||||||
|
digits: null,
|
||||||
|
dateFormat: 'dd/mm/yyyy',
|
||||||
|
firstDay: 0,
|
||||||
|
isRTL: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Determine whether this date is in a leap year.
|
||||||
|
@memberof EthiopianCalendar
|
||||||
|
@param {CDate|number} year The date to examine or the year to examine.
|
||||||
|
@return {boolean} <code>true</code> if this is a leap year, <code>false</code> if not.
|
||||||
|
@throws Error if an invalid year or a different calendar used. */
|
||||||
|
leapYear: function(year) {
|
||||||
|
var date = this._validate(year, this.minMonth, this.minDay, $.calendars.local.invalidYear);
|
||||||
|
year = date.year() + (date.year() < 0 ? 1 : 0); // No year zero
|
||||||
|
return year % 4 === 3 || year % 4 === -1;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Retrieve the number of months in a year.
|
||||||
|
@memberof EthiopianCalendar
|
||||||
|
@param {CDate|number} year The date to examine or the year to examine.
|
||||||
|
@return {number} The number of months.
|
||||||
|
@throws Error if an invalid year or a different calendar used. */
|
||||||
|
monthsInYear: function(year) {
|
||||||
|
this._validate(year, this.minMonth, this.minDay,
|
||||||
|
$.calendars.local.invalidYear || $.calendars.regionalOptions[''].invalidYear);
|
||||||
|
return 13;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Determine the week of the year for a date.
|
||||||
|
@memberof EthiopianCalendar
|
||||||
|
@param {CDate|number} year The date to examine or the year to examine.
|
||||||
|
@param {number} [month] The month to examine (if only <code>year</code> specified above).
|
||||||
|
@param {number} [day] The day to examine (if only <code>year</code> specified above).
|
||||||
|
@return {number} The week of the year.
|
||||||
|
@throws Error if an invalid date or a different calendar used. */
|
||||||
|
weekOfYear: function(year, month, day) {
|
||||||
|
// Find Sunday of this week starting on Sunday
|
||||||
|
var checkDate = this.newDate(year, month, day);
|
||||||
|
checkDate.add(-checkDate.dayOfWeek(), 'd');
|
||||||
|
return Math.floor((checkDate.dayOfYear() - 1) / 7) + 1;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Retrieve the number of days in a month.
|
||||||
|
@memberof EthiopianCalendar
|
||||||
|
@param {CDate|number} year The date to examine or the year of the month.
|
||||||
|
@param {number} [month] The month (if only <code>year</code> specified above).
|
||||||
|
@return {number} The number of days in this month.
|
||||||
|
@throws Error if an invalid month/year or a different calendar used. */
|
||||||
|
daysInMonth: function(year, month) {
|
||||||
|
var date = this._validate(year, month, this.minDay, $.calendars.local.invalidMonth);
|
||||||
|
return this.daysPerMonth[date.month() - 1] +
|
||||||
|
(date.month() === 13 && this.leapYear(date.year()) ? 1 : 0);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Determine whether this date is a week day.
|
||||||
|
@memberof EthiopianCalendar
|
||||||
|
@param {CDate|number} year The date to examine or the year to examine.
|
||||||
|
@param {number} [month] The month to examine (if only <code>year</code> specified above).
|
||||||
|
@param {number} [day] The day to examine (if only <code>year</code> specified above).
|
||||||
|
@return {boolean} <code>true</code> if a week day, <code>false</code> if not.
|
||||||
|
@throws Error if an invalid date or a different calendar used. */
|
||||||
|
weekDay: function(year, month, day) {
|
||||||
|
return (this.dayOfWeek(year, month, day) || 7) < 6;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Retrieve the Julian date equivalent for this date,
|
||||||
|
i.e. days since January 1, 4713 BCE Greenwich noon.
|
||||||
|
@memberof EthiopianCalendar
|
||||||
|
@param {CDate|number} year The date to convert or the year to convert.
|
||||||
|
@param {number} [month] The month to convert (if only <code>year</code> specified above).
|
||||||
|
@param {number} [day] The day to convert (if only <code>year</code> specified above).
|
||||||
|
@return {number} The equivalent Julian date.
|
||||||
|
@throws Error if an invalid date or a different calendar used. */
|
||||||
|
toJD: function(year, month, day) {
|
||||||
|
var date = this._validate(year, month, day, $.calendars.local.invalidDate);
|
||||||
|
year = date.year();
|
||||||
|
if (year < 0) { year++; } // No year zero
|
||||||
|
return date.day() + (date.month() - 1) * 30 +
|
||||||
|
(year - 1) * 365 + Math.floor(year / 4) + this.jdEpoch - 1;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Create a new date from a Julian date.
|
||||||
|
@memberof EthiopianCalendar
|
||||||
|
@param {number} jd the Julian date to convert.
|
||||||
|
@return {CDate} the equivalent date. */
|
||||||
|
fromJD: function(jd) {
|
||||||
|
var c = Math.floor(jd) + 0.5 - this.jdEpoch;
|
||||||
|
var year = Math.floor((c - Math.floor((c + 366) / 1461)) / 365) + 1;
|
||||||
|
if (year <= 0) { year--; } // No year zero
|
||||||
|
c = Math.floor(jd) + 0.5 - this.newDate(year, 1, 1).toJD();
|
||||||
|
var month = Math.floor(c / 30) + 1;
|
||||||
|
var day = c - (month - 1) * 30 + 1;
|
||||||
|
return this.newDate(year, month, day);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ethiopian calendar implementation
|
||||||
|
$.calendars.calendars.ethiopian = EthiopianCalendar;
|
||||||
|
|
||||||
|
})(jQuery);
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
!function(a){"use strict";function b(a){this.local=this.regionalOptions[a||""]||this.regionalOptions[""]}b.prototype=new a.calendars.baseCalendar,a.extend(b.prototype,{name:"Ethiopian",jdEpoch:1724220.5,daysPerMonth:[30,30,30,30,30,30,30,30,30,30,30,30,5],hasYearZero:!1,minMonth:1,firstMonth:1,minDay:1,regionalOptions:{"":{name:"Ethiopian",epochs:["BEE","EE"],monthNames:["Meskerem","Tikemet","Hidar","Tahesas","Tir","Yekatit","Megabit","Miazia","Genbot","Sene","Hamle","Nehase","Pagume"],monthNamesShort:["Mes","Tik","Hid","Tah","Tir","Yek","Meg","Mia","Gen","Sen","Ham","Neh","Pag"],dayNames:["Ehud","Segno","Maksegno","Irob","Hamus","Arb","Kidame"],dayNamesShort:["Ehu","Seg","Mak","Iro","Ham","Arb","Kid"],dayNamesMin:["Eh","Se","Ma","Ir","Ha","Ar","Ki"],digits:null,dateFormat:"dd/mm/yyyy",firstDay:0,isRTL:!1}},leapYear:function(b){var c=this._validate(b,this.minMonth,this.minDay,a.calendars.local.invalidYear);return b=c.year()+(c.year()<0?1:0),b%4===3||b%4===-1},monthsInYear:function(b){return this._validate(b,this.minMonth,this.minDay,a.calendars.local.invalidYear||a.calendars.regionalOptions[""].invalidYear),13},weekOfYear:function(a,b,c){var d=this.newDate(a,b,c);return d.add(-d.dayOfWeek(),"d"),Math.floor((d.dayOfYear()-1)/7)+1},daysInMonth:function(b,c){var d=this._validate(b,c,this.minDay,a.calendars.local.invalidMonth);return this.daysPerMonth[d.month()-1]+(13===d.month()&&this.leapYear(d.year())?1:0)},weekDay:function(a,b,c){return(this.dayOfWeek(a,b,c)||7)<6},toJD:function(b,c,d){var e=this._validate(b,c,d,a.calendars.local.invalidDate);return b=e.year(),b<0&&b++,e.day()+30*(e.month()-1)+365*(b-1)+Math.floor(b/4)+this.jdEpoch-1},fromJD:function(a){var b=Math.floor(a)+.5-this.jdEpoch,c=Math.floor((b-Math.floor((b+366)/1461))/365)+1;c<=0&&c--,b=Math.floor(a)+.5-this.newDate(c,1,1).toJD();var d=Math.floor(b/30)+1,e=b-30*(d-1)+1;return this.newDate(c,d,e)}}),a.calendars.calendars.ethiopian=b}(jQuery);
|
||||||
|
//# sourceMappingURL=jquery.calendars.ethiopian.min.map
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"sources":["..\\..\\src\\js\\jquery.calendars.ethiopian.js"],"names":["$","EthiopianCalendar","language","this","local","regionalOptions","prototype","calendars","baseCalendar","extend","name","jdEpoch","daysPerMonth","hasYearZero","minMonth","firstMonth","minDay","","epochs","monthNames","monthNamesShort","dayNames","dayNamesShort","dayNamesMin","digits","dateFormat","firstDay","isRTL","leapYear","year","date","_validate","invalidYear","monthsInYear","weekOfYear","month","day","checkDate","newDate","add","dayOfWeek","Math","floor","dayOfYear","daysInMonth","invalidMonth","weekDay","toJD","invalidDate","fromJD","jd","c","ethiopian","jQuery"],"mappings":"CAMA,SAAUA,GACT,YAQA,SAASC,GAAkBC,GAC1BC,KAAKC,MAAQD,KAAKE,gBAAgBH,GAAY,KAAOC,KAAKE,gBAAgB,IAG3EJ,EAAkBK,UAAY,GAAIN,GAAEO,UAAUC,aAE9CR,EAAES,OAAOR,EAAkBK,WAG1BI,KAAM,YAGNC,QAAS,UAGTC,cAAe,GAAI,GAAI,GAAI,GAAI,GAAI,GAAI,GAAI,GAAI,GAAI,GAAI,GAAI,GAAI,GAG/DC,aAAa,EAGbC,SAAU,EAGVC,WAAY,EAGZC,OAAQ,EAiBRX,iBACCY,IACCP,KAAM,YACNQ,QAAS,MAAO,MAChBC,YAAa,WAAY,UAAW,QAAS,UAAW,MAAO,UAC/D,UAAW,SAAU,SAAU,OAAQ,QAAS,SAAU,UAC1DC,iBAAkB,MAAO,MAAO,MAAO,MAAO,MAAO,MACrD,MAAO,MAAO,MAAO,MAAO,MAAO,MAAO,OAC1CC,UAAW,OAAQ,QAAS,WAAY,OAAQ,QAAS,MAAO,UAChEC,eAAgB,MAAO,MAAO,MAAO,MAAO,MAAO,MAAO,OAC1DC,aAAc,KAAM,KAAM,KAAM,KAAM,KAAM,KAAM,MAClDC,OAAQ,KACRC,WAAY,aACZC,SAAU,EACVC,OAAO,IASTC,SAAU,SAASC,GAClB,GAAIC,GAAO3B,KAAK4B,UAAUF,EAAM1B,KAAKW,SAAUX,KAAKa,OAAQhB,EAAEO,UAAUH,MAAM4B,YAE9E,OADAH,GAAOC,EAAKD,QAAUC,EAAKD,OAAS,EAAI,EAAI,GACrCA,EAAO,IAAM,GAAKA,EAAO,KAAM,GAQvCI,aAAc,SAASJ,GAGtB,MAFA1B,MAAK4B,UAAUF,EAAM1B,KAAKW,SAAUX,KAAKa,OACxChB,EAAEO,UAAUH,MAAM4B,aAAehC,EAAEO,UAAUF,gBAAgB,IAAI2B,aAC3D,IAURE,WAAY,SAASL,EAAMM,EAAOC,GAEjC,GAAIC,GAAYlC,KAAKmC,QAAQT,EAAMM,EAAOC,EAE1C,OADAC,GAAUE,KAAKF,EAAUG,YAAa,KAC/BC,KAAKC,OAAOL,EAAUM,YAAc,GAAK,GAAK,GAStDC,YAAa,SAASf,EAAMM,GAC3B,GAAIL,GAAO3B,KAAK4B,UAAUF,EAAMM,EAAOhC,KAAKa,OAAQhB,EAAEO,UAAUH,MAAMyC,aACtE,OAAO1C,MAAKS,aAAakB,EAAKK,QAAU,IACrB,KAAjBL,EAAKK,SAAkBhC,KAAKyB,SAASE,EAAKD,QAAU,EAAI,IAU3DiB,QAAS,SAASjB,EAAMM,EAAOC,GAC9B,OAAQjC,KAAKqC,UAAUX,EAAMM,EAAOC,IAAQ,GAAK,GAWlDW,KAAM,SAASlB,EAAMM,EAAOC,GAC3B,GAAIN,GAAO3B,KAAK4B,UAAUF,EAAMM,EAAOC,EAAKpC,EAAEO,UAAUH,MAAM4C,YAG9D,OAFAnB,GAAOC,EAAKD,OACRA,EAAO,GAAKA,IACTC,EAAKM,MAA6B,IAApBN,EAAKK,QAAU,GACtB,KAAZN,EAAO,GAAWY,KAAKC,MAAMb,EAAO,GAAK1B,KAAKQ,QAAU,GAO3DsC,OAAQ,SAASC,GAChB,GAAIC,GAAIV,KAAKC,MAAMQ,GAAM,GAAM/C,KAAKQ,QAChCkB,EAAOY,KAAKC,OAAOS,EAAIV,KAAKC,OAAOS,EAAI,KAAO,OAAS,KAAO,CAC9DtB,IAAQ,GAAKA,IACjBsB,EAAIV,KAAKC,MAAMQ,GAAM,GAAM/C,KAAKmC,QAAQT,EAAM,EAAG,GAAGkB,MACpD,IAAIZ,GAAQM,KAAKC,MAAMS,EAAI,IAAM,EAC7Bf,EAAMe,EAAkB,IAAbhB,EAAQ,GAAU,CACjC,OAAOhC,MAAKmC,QAAQT,EAAMM,EAAOC,MAKnCpC,EAAEO,UAAUA,UAAU6C,UAAYnD,GAEhCoD","file":"jquery.calendars.ethiopian.min.js"}
|
||||||
@@ -0,0 +1,982 @@
|
|||||||
|
/* http://keith-wood.name/calendars.html
|
||||||
|
Calendars for jQuery v2.1.0.
|
||||||
|
Written by Keith Wood (wood.keith{at}optusnet.com.au) August 2009.
|
||||||
|
Available under the MIT (http://keith-wood.name/licence.html) license.
|
||||||
|
Please attribute the author if you use it. */
|
||||||
|
|
||||||
|
(function($) { // Hide scope, no $ conflict
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
function Calendars() {
|
||||||
|
this.regionalOptions = [];
|
||||||
|
/** Localised values.
|
||||||
|
@memberof Calendars
|
||||||
|
@property {string} [invalidCalendar='Calendar {0} not found']
|
||||||
|
Error message for an unknown calendar.
|
||||||
|
@property {string} [invalidDate='Invalid {0} date']
|
||||||
|
Error message for an invalid date for this calendar.
|
||||||
|
@property {string} [invalidMonth='Invalid {0} month']
|
||||||
|
Error message for an invalid month for this calendar.
|
||||||
|
@property {string} [invalidYear='Invalid {0} year']
|
||||||
|
Error message for an invalid year for this calendar.
|
||||||
|
@property {string} [differentCalendars='Cannot mix {0} and {1} dates']
|
||||||
|
Error message for mixing different calendars. */
|
||||||
|
this.regionalOptions[''] = {
|
||||||
|
invalidCalendar: 'Calendar {0} not found',
|
||||||
|
invalidDate: 'Invalid {0} date',
|
||||||
|
invalidMonth: 'Invalid {0} month',
|
||||||
|
invalidYear: 'Invalid {0} year',
|
||||||
|
differentCalendars: 'Cannot mix {0} and {1} dates'
|
||||||
|
};
|
||||||
|
this.local = this.regionalOptions[''];
|
||||||
|
this.calendars = {};
|
||||||
|
this._localCals = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create the calendars plugin.
|
||||||
|
<p>Provides support for various world calendars in a consistent manner.</p>
|
||||||
|
<p>Use the global instance, <code>$.calendars</code>, to access the functionality.</p>
|
||||||
|
@class Calendars
|
||||||
|
@example $.calendars.instance('julian').newDate(2014, 12, 25) */
|
||||||
|
$.extend(Calendars.prototype, {
|
||||||
|
|
||||||
|
/** Obtain a calendar implementation and localisation.
|
||||||
|
@memberof Calendars
|
||||||
|
@param {string} [name='gregorian'] The name of the calendar, e.g. 'gregorian', 'persian', 'islamic'.
|
||||||
|
@param {string} [language=''] The language code to use for localisation (default is English).
|
||||||
|
@return {Calendar} The calendar and localisation.
|
||||||
|
@throws Error if calendar not found.
|
||||||
|
@example $.calendars.instance()
|
||||||
|
$.calendars.instance('persian')
|
||||||
|
$.calendars.instance('hebrew', 'he') */
|
||||||
|
instance: function(name, language) {
|
||||||
|
name = (name || 'gregorian').toLowerCase();
|
||||||
|
language = language || '';
|
||||||
|
var cal = this._localCals[name + '-' + language];
|
||||||
|
if (!cal && this.calendars[name]) {
|
||||||
|
cal = new this.calendars[name](language);
|
||||||
|
this._localCals[name + '-' + language] = cal;
|
||||||
|
}
|
||||||
|
if (!cal) {
|
||||||
|
throw (this.local.invalidCalendar || this.regionalOptions[''].invalidCalendar).
|
||||||
|
replace(/\{0\}/, name);
|
||||||
|
}
|
||||||
|
return cal;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Create a new date - for today if no other parameters given.
|
||||||
|
@memberof Calendars
|
||||||
|
@param {CDate|number} [year] The date to copy or the year for the date.
|
||||||
|
@param {number} [month] The month for the date (if numeric <code>year</code> specified above).
|
||||||
|
@param {number} [day] The day for the date (if numeric <code>year</code> specified above).
|
||||||
|
@param {BaseCalendar|string} [calendar='gregorian'] The underlying calendar or the name of the calendar.
|
||||||
|
@param {string} [language=''] The language to use for localisation (default English).
|
||||||
|
@return {CDate} The new date.
|
||||||
|
@throws Error if an invalid date.
|
||||||
|
@example $.calendars.newDate()
|
||||||
|
$.calendars.newDate(otherDate)
|
||||||
|
$.calendars.newDate(2001, 1, 1)
|
||||||
|
$.calendars.newDate(1379, 10, 12, 'persian') */
|
||||||
|
newDate: function(year, month, day, calendar, language) {
|
||||||
|
calendar = ((typeof year !== 'undefined' && year !== null) && year.year ? year.calendar() :
|
||||||
|
(typeof calendar === 'string' ? this.instance(calendar, language) : calendar)) || this.instance();
|
||||||
|
return calendar.newDate(year, month, day);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** A simple digit substitution function for localising numbers via the
|
||||||
|
{@linkcode GregorianCalendar.regionalOptions|Calendar digits} option.
|
||||||
|
@memberof Calendars
|
||||||
|
@param {string[]} digits The substitute digits, for 0 through 9.
|
||||||
|
@return {CalendarsDigits} The substitution function.
|
||||||
|
@example digits: $.calendars.substituteDigits(['۰', '۱', '۲', '۳', '۴', '۵', '۶', '۷', '۸', '۹']) */
|
||||||
|
substituteDigits: function(digits) {
|
||||||
|
return function(value) {
|
||||||
|
return (value + '').replace(/[0-9]/g, function(digit) {
|
||||||
|
return digits[digit];
|
||||||
|
});
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Digit substitution function for localising Chinese style numbers via the
|
||||||
|
{@linkcode GregorianCalendar.regionalOptions|Calendar digits} option.
|
||||||
|
@memberof Calendars
|
||||||
|
@param {string[]} digits The substitute digits, for 0 through 9.
|
||||||
|
@param {string[]} powers The characters denoting powers of 10, i.e. 1, 10, 100, 1000.
|
||||||
|
@return {CalendarsDigits} The substitution function.
|
||||||
|
@example digits: $.calendars.substituteChineseDigits(
|
||||||
|
['〇', '一', '二', '三', '四', '五', '六', '七', '八', '九'], ['', '十', '百', '千']) */
|
||||||
|
substituteChineseDigits: function(digits, powers) {
|
||||||
|
return function(value) {
|
||||||
|
var localNumber = '';
|
||||||
|
var power = 0;
|
||||||
|
while (value > 0) {
|
||||||
|
var units = value % 10;
|
||||||
|
localNumber = (units === 0 ? '' : digits[units] + powers[power]) + localNumber;
|
||||||
|
power++;
|
||||||
|
value = Math.floor(value / 10);
|
||||||
|
}
|
||||||
|
if (localNumber.indexOf(digits[1] + powers[1]) === 0) {
|
||||||
|
localNumber = localNumber.substr(1);
|
||||||
|
}
|
||||||
|
return localNumber || digits[0];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Generic date, based on a particular calendar.
|
||||||
|
@class CDate
|
||||||
|
@param {BaseCalendar} calendar The underlying calendar implementation.
|
||||||
|
@param {number} year The year for this date.
|
||||||
|
@param {number} month The month for this date.
|
||||||
|
@param {number} day The day for this date.
|
||||||
|
@return {CDate} The date object.
|
||||||
|
@throws Error if an invalid date. */
|
||||||
|
function CDate(calendar, year, month, day) {
|
||||||
|
this._calendar = calendar;
|
||||||
|
this._year = year;
|
||||||
|
this._month = month;
|
||||||
|
this._day = day;
|
||||||
|
if (this._calendar._validateLevel === 0 &&
|
||||||
|
!this._calendar.isValid(this._year, this._month, this._day)) {
|
||||||
|
throw ($.calendars.local.invalidDate || $.calendars.regionalOptions[''].invalidDate).
|
||||||
|
replace(/\{0\}/, this._calendar.local.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pad a numeric value with leading zeroes.
|
||||||
|
@private
|
||||||
|
@param {number} value The number to format.
|
||||||
|
@param {number} length The minimum length.
|
||||||
|
@return {string} The formatted number. */
|
||||||
|
function pad(value, length) {
|
||||||
|
value = '' + value;
|
||||||
|
return '000000'.substring(0, length - value.length) + value;
|
||||||
|
}
|
||||||
|
|
||||||
|
$.extend(CDate.prototype, {
|
||||||
|
|
||||||
|
/** Create a new date.
|
||||||
|
@memberof CDate
|
||||||
|
@param {CDate|number} [year] The date to copy or the year for the date (default to this date).
|
||||||
|
@param {number} [month] The month for the date (if numeric <code>year</code> specified above).
|
||||||
|
@param {number} [day] The day for the date (if numeric <code>year</code> specified above).
|
||||||
|
@return {CDate} The new date.
|
||||||
|
@throws Error if an invalid date.
|
||||||
|
@example date.newDate()
|
||||||
|
date.newDate(otherDate)
|
||||||
|
date.newDate(2001, 1, 1) */
|
||||||
|
newDate: function(year, month, day) {
|
||||||
|
return this._calendar.newDate((typeof year === 'undefined' || year === null ? this : year), month, day);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Set or retrieve the year for this date.
|
||||||
|
@memberof CDate
|
||||||
|
@param {number} [year] The year for the date.
|
||||||
|
@return {number|CDate} The date's year (if no parameter) or the updated date.
|
||||||
|
@throws Error if an invalid date.
|
||||||
|
@example date.year(2001)
|
||||||
|
var year = date.year() */
|
||||||
|
year: function(year) {
|
||||||
|
return (arguments.length === 0 ? this._year : this.set(year, 'y'));
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Set or retrieve the month for this date.
|
||||||
|
@memberof CDate
|
||||||
|
@param {number} [month] The month for the date.
|
||||||
|
@return {number|CDate} The date's month (if no parameter) or the updated date.
|
||||||
|
@throws Error if an invalid date.
|
||||||
|
@example date.month(1)
|
||||||
|
var month = date.month() */
|
||||||
|
month: function(month) {
|
||||||
|
return (arguments.length === 0 ? this._month : this.set(month, 'm'));
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Set or retrieve the day for this date.
|
||||||
|
@memberof CDate
|
||||||
|
@param {number} [day] The day for the date.
|
||||||
|
@return {number|CData} The date's day (if no parameter) or the updated date.
|
||||||
|
@throws Error if an invalid date.
|
||||||
|
@example date.day(1)
|
||||||
|
var day = date.day() */
|
||||||
|
day: function(day) {
|
||||||
|
return (arguments.length === 0 ? this._day : this.set(day, 'd'));
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Set new values for this date.
|
||||||
|
@memberof CDate
|
||||||
|
@param {number} year The year for the date.
|
||||||
|
@param {number} month The month for the date.
|
||||||
|
@param {number} day The day for the date.
|
||||||
|
@return {CDate} The updated date.
|
||||||
|
@throws Error if an invalid date.
|
||||||
|
@example date.date(2001, 1, 1) */
|
||||||
|
date: function(year, month, day) {
|
||||||
|
if (!this._calendar.isValid(year, month, day)) {
|
||||||
|
throw ($.calendars.local.invalidDate || $.calendars.regionalOptions[''].invalidDate).
|
||||||
|
replace(/\{0\}/, this._calendar.local.name);
|
||||||
|
}
|
||||||
|
this._year = year;
|
||||||
|
this._month = month;
|
||||||
|
this._day = day;
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Determine whether this date is in a leap year.
|
||||||
|
@memberof CDate
|
||||||
|
@return {boolean} <code>true</code> if this is a leap year, <code>false</code> if not.
|
||||||
|
@example if (date.leapYear()) ...*/
|
||||||
|
leapYear: function() {
|
||||||
|
return this._calendar.leapYear(this);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Retrieve the epoch designator for this date, e.g. BCE or CE.
|
||||||
|
@memberof CDate
|
||||||
|
@return {string} The current epoch.
|
||||||
|
@example var epoch = date.epoch() */
|
||||||
|
epoch: function() {
|
||||||
|
return this._calendar.epoch(this);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Format the year, if not a simple sequential number.
|
||||||
|
@memberof CDate
|
||||||
|
@return {string} The formatted year.
|
||||||
|
@example var year = date.formatYear() */
|
||||||
|
formatYear: function() {
|
||||||
|
return this._calendar.formatYear(this);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Retrieve the month of the year for this date,
|
||||||
|
i.e. the month's position within a numbered year.
|
||||||
|
@memberof CDate
|
||||||
|
@return {number} The month of the year: <code>minMonth</code> to months per year.
|
||||||
|
@example var month = date.monthOfYear() */
|
||||||
|
monthOfYear: function() {
|
||||||
|
return this._calendar.monthOfYear(this);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Retrieve the week of the year for this date.
|
||||||
|
@memberof CDate
|
||||||
|
@return {number} The week of the year: 1 to weeks per year.
|
||||||
|
@example var week = date.weekOfYear() */
|
||||||
|
weekOfYear: function() {
|
||||||
|
return this._calendar.weekOfYear(this);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Retrieve the number of days in the year for this date.
|
||||||
|
@memberof CDate
|
||||||
|
@return {number} The number of days in this year.
|
||||||
|
@example var days = date.daysInYear() */
|
||||||
|
daysInYear: function() {
|
||||||
|
return this._calendar.daysInYear(this);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Retrieve the day of the year for this date.
|
||||||
|
@memberof CDate
|
||||||
|
@return {number} The day of the year: 1 to days per year.
|
||||||
|
@example var doy = date.dayOfYear() */
|
||||||
|
dayOfYear: function() {
|
||||||
|
return this._calendar.dayOfYear(this);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Retrieve the number of days in the month for this date.
|
||||||
|
@memberof CDate
|
||||||
|
@return {number} The number of days.
|
||||||
|
@example var days = date.daysInMonth() */
|
||||||
|
daysInMonth: function() {
|
||||||
|
return this._calendar.daysInMonth(this);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Retrieve the day of the week for this date.
|
||||||
|
@memberof CDate
|
||||||
|
@return {number} The day of the week: 0 to number of days - 1.
|
||||||
|
@example var dow = date.dayOfWeek() */
|
||||||
|
dayOfWeek: function() {
|
||||||
|
return this._calendar.dayOfWeek(this);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Determine whether this date is a week day.
|
||||||
|
@memberof CDate
|
||||||
|
@return {boolean} <code>true</code> if a week day, <code>false</code> if not.
|
||||||
|
@example if (date.weekDay()) ... */
|
||||||
|
weekDay: function() {
|
||||||
|
return this._calendar.weekDay(this);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Retrieve additional information about this date.
|
||||||
|
@memberof CDate
|
||||||
|
@return {object} Additional information - contents depends on calendar.
|
||||||
|
@example var info = date.extraInfo() */
|
||||||
|
extraInfo: function() {
|
||||||
|
return this._calendar.extraInfo(this);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Add period(s) to a date.
|
||||||
|
@memberof CDate
|
||||||
|
@param {number} offset The number of periods to adjust by.
|
||||||
|
@param {string} period One of 'y' for years, 'm' for months, 'w' for weeks, 'd' for days.
|
||||||
|
@return {CDate} The updated date.
|
||||||
|
@example date.add(10, 'd') */
|
||||||
|
add: function(offset, period) {
|
||||||
|
return this._calendar.add(this, offset, period);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Set a portion of the date.
|
||||||
|
@memberof CDate
|
||||||
|
@param {number} value The new value for the period.
|
||||||
|
@param {string} period One of 'y' for year, 'm' for month, 'd' for day.
|
||||||
|
@return {CDate} The updated date.
|
||||||
|
@throws Error if not a valid date.
|
||||||
|
@example date.set(10, 'd') */
|
||||||
|
set: function(value, period) {
|
||||||
|
return this._calendar.set(this, value, period);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Compare this date to another date.
|
||||||
|
@memberof CDate
|
||||||
|
@param {CDate} date The other date.
|
||||||
|
@return {number} -1 if this date is before the other date,
|
||||||
|
0 if they are equal, or +1 if this date is after the other date.
|
||||||
|
@example if (date1.compareTo(date2) < 0) ... */
|
||||||
|
compareTo: function(date) {
|
||||||
|
if (this._calendar.name !== date._calendar.name) {
|
||||||
|
throw ($.calendars.local.differentCalendars || $.calendars.regionalOptions[''].differentCalendars).
|
||||||
|
replace(/\{0\}/, this._calendar.local.name).replace(/\{1\}/, date._calendar.local.name);
|
||||||
|
}
|
||||||
|
var c = (this._year !== date._year ? this._year - date._year :
|
||||||
|
this._month !== date._month ? this.monthOfYear() - date.monthOfYear() :
|
||||||
|
this._day - date._day);
|
||||||
|
return (c === 0 ? 0 : (c < 0 ? -1 : +1));
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Retrieve the calendar backing this date.
|
||||||
|
@memberof CDate
|
||||||
|
@return {BaseCalendar} The calendar implementation.
|
||||||
|
@example var cal = date.calendar() */
|
||||||
|
calendar: function() {
|
||||||
|
return this._calendar;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Retrieve the Julian date equivalent for this date,
|
||||||
|
i.e. days since January 1, 4713 BCE Greenwich noon.
|
||||||
|
@memberof CDate
|
||||||
|
@return {number} The equivalent Julian date.
|
||||||
|
@example var jd = date.toJD() */
|
||||||
|
toJD: function() {
|
||||||
|
return this._calendar.toJD(this);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Create a new date from a Julian date.
|
||||||
|
@memberof CDate
|
||||||
|
@param {number} jd The Julian date to convert.
|
||||||
|
@return {CDate} The equivalent date.
|
||||||
|
@example var date2 = date1.fromJD(jd) */
|
||||||
|
fromJD: function(jd) {
|
||||||
|
return this._calendar.fromJD(jd);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Convert this date to a standard (Gregorian) JavaScript Date.
|
||||||
|
@memberof CDate
|
||||||
|
@return {Date} The equivalent JavaScript date.
|
||||||
|
@example var jsd = date.toJSDate() */
|
||||||
|
toJSDate: function() {
|
||||||
|
return this._calendar.toJSDate(this);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Create a new date from a standard (Gregorian) JavaScript Date.
|
||||||
|
@memberof CDate
|
||||||
|
@param {Date} jsd The JavaScript date to convert.
|
||||||
|
@return {CDate} The equivalent date.
|
||||||
|
@example var date2 = date1.fromJSDate(jsd) */
|
||||||
|
fromJSDate: function(jsd) {
|
||||||
|
return this._calendar.fromJSDate(jsd);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Convert to a string for display.
|
||||||
|
@memberof CDate
|
||||||
|
@return {string} This date as a string. */
|
||||||
|
toString: function() {
|
||||||
|
return (this.year() < 0 ? '-' : '') + pad(Math.abs(this.year()), 4) +
|
||||||
|
'-' + pad(this.month(), 2) + '-' + pad(this.day(), 2);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Basic functionality for all calendars.
|
||||||
|
Other calendars should extend this:
|
||||||
|
<pre>OtherCalendar.prototype = new BaseCalendar();</pre>
|
||||||
|
@class BaseCalendar */
|
||||||
|
function BaseCalendar() {
|
||||||
|
this.shortYearCutoff = '+10';
|
||||||
|
}
|
||||||
|
|
||||||
|
$.extend(BaseCalendar.prototype, {
|
||||||
|
_validateLevel: 0, // "Stack" to turn validation on/off
|
||||||
|
|
||||||
|
/** Create a new date within this calendar - today if no parameters given.
|
||||||
|
@memberof BaseCalendar
|
||||||
|
@param {CDate|number} year The date to duplicate or the year for the date.
|
||||||
|
@param {number} [month] The month for the date (if numeric <code>year</code> specified above).
|
||||||
|
@param {number} [day] The day for the date (if numeric <code>year</code> specified above).
|
||||||
|
@return {CDate} The new date.
|
||||||
|
@throws Error if not a valid date or a different calendar is used.
|
||||||
|
@example var date = calendar.newDate(2014, 1, 26)
|
||||||
|
var date2 = calendar.newDate(date1)
|
||||||
|
var today = calendar.newDate() */
|
||||||
|
newDate: function(year, month, day) {
|
||||||
|
if (typeof year === 'undefined' || year === null) {
|
||||||
|
return this.today();
|
||||||
|
}
|
||||||
|
if (year.year) {
|
||||||
|
this._validate(year, month, day,
|
||||||
|
$.calendars.local.invalidDate || $.calendars.regionalOptions[''].invalidDate);
|
||||||
|
day = year.day();
|
||||||
|
month = year.month();
|
||||||
|
year = year.year();
|
||||||
|
}
|
||||||
|
return new CDate(this, year, month, day);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Create a new date for today.
|
||||||
|
@memberof BaseCalendar
|
||||||
|
@return {CDate} Today's date.
|
||||||
|
@example var today = calendar.today() */
|
||||||
|
today: function() {
|
||||||
|
return this.fromJSDate(new Date());
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Retrieve the epoch designator for this date.
|
||||||
|
@memberof BaseCalendar
|
||||||
|
@param {CDate|number} year The date to examine or the year to examine.
|
||||||
|
@return {string} The current epoch.
|
||||||
|
@throws Error if an invalid year or a different calendar is used.
|
||||||
|
@example var epoch = calendar.epoch(date)
|
||||||
|
var epoch = calendar.epoch(2014) */
|
||||||
|
epoch: function(year) {
|
||||||
|
var date = this._validate(year, this.minMonth, this.minDay,
|
||||||
|
$.calendars.local.invalidYear || $.calendars.regionalOptions[''].invalidYear);
|
||||||
|
return (date.year() < 0 ? this.local.epochs[0] : this.local.epochs[1]);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Format the year, if not a simple sequential number
|
||||||
|
@memberof BaseCalendar
|
||||||
|
@param {CDate|number} year The date to format or the year to format.
|
||||||
|
@return {string} The formatted year.
|
||||||
|
@throws Error if an invalid year or a different calendar is used.
|
||||||
|
@example var year = calendar.formatYear(date)
|
||||||
|
var year = calendar.formatYear(2014) */
|
||||||
|
formatYear: function(year) {
|
||||||
|
var date = this._validate(year, this.minMonth, this.minDay,
|
||||||
|
$.calendars.local.invalidYear || $.calendars.regionalOptions[''].invalidYear);
|
||||||
|
return (date.year() < 0 ? '-' : '') + pad(Math.abs(date.year()), 4);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Retrieve the number of months in a year.
|
||||||
|
@memberof BaseCalendar
|
||||||
|
@param {CDate|number} year The date to examine or the year to examine.
|
||||||
|
@return {number} The number of months.
|
||||||
|
@throws Error if an invalid year or a different calendar is used.
|
||||||
|
@example var months = calendar.monthsInYear(date)
|
||||||
|
var months = calendar.monthsInYear(2014) */
|
||||||
|
monthsInYear: function(year) {
|
||||||
|
this._validate(year, this.minMonth, this.minDay,
|
||||||
|
$.calendars.local.invalidYear || $.calendars.regionalOptions[''].invalidYear);
|
||||||
|
return 12;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Calculate the month's ordinal position within the year -
|
||||||
|
for those calendars that don't start at month 1!
|
||||||
|
@memberof BaseCalendar
|
||||||
|
@param {CDate|number} year The date to examine or the year to examine.
|
||||||
|
@param {number} [month] The month to examine (if numeric <code>year</code> specified above).
|
||||||
|
@return {number} The ordinal position, starting from <code>minMonth</code>.
|
||||||
|
@throws Error if an invalid year/month or a different calendar is used.
|
||||||
|
@example var pos = calendar.monthOfYear(date)
|
||||||
|
var pos = calendar.monthOfYear(2014, 7) */
|
||||||
|
monthOfYear: function(year, month) {
|
||||||
|
var date = this._validate(year, month, this.minDay,
|
||||||
|
$.calendars.local.invalidMonth || $.calendars.regionalOptions[''].invalidMonth);
|
||||||
|
return (date.month() + this.monthsInYear(date) - this.firstMonth) %
|
||||||
|
this.monthsInYear(date) + this.minMonth;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Calculate actual month from ordinal position, starting from <code>minMonth</code>.
|
||||||
|
@memberof BaseCalendar
|
||||||
|
@param {number} year The year to examine.
|
||||||
|
@param {number} ord The month's ordinal position.
|
||||||
|
@return {number} The month's number.
|
||||||
|
@throws Error if an invalid year/month.
|
||||||
|
@example var month = calendar.fromMonthOfYear(2014, 7) */
|
||||||
|
fromMonthOfYear: function(year, ord) {
|
||||||
|
var m = (ord + this.firstMonth - 2 * this.minMonth) %
|
||||||
|
this.monthsInYear(year) + this.minMonth;
|
||||||
|
this._validate(year, m, this.minDay,
|
||||||
|
$.calendars.local.invalidMonth || $.calendars.regionalOptions[''].invalidMonth);
|
||||||
|
return m;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Retrieve the number of days in a year.
|
||||||
|
@memberof BaseCalendar
|
||||||
|
@param {CDate|number} year The date to examine or the year to examine.
|
||||||
|
@return {number} The number of days.
|
||||||
|
@throws Error if an invalid year or a different calendar is used.
|
||||||
|
@example var days = calendar.daysInYear(date)
|
||||||
|
var days = calendar.daysInYear(2014) */
|
||||||
|
daysInYear: function(year) {
|
||||||
|
var date = this._validate(year, this.minMonth, this.minDay,
|
||||||
|
$.calendars.local.invalidYear || $.calendars.regionalOptions[''].invalidYear);
|
||||||
|
return (this.leapYear(date) ? 366 : 365);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Retrieve the day of the year for a date.
|
||||||
|
@memberof BaseCalendar
|
||||||
|
@param {CDate|number} year The date to convert or the year to convert.
|
||||||
|
@param {number} [month] The month to convert (if numeric <code>year</code> specified above).
|
||||||
|
@param {number} [day] The day to convert (if numeric <code>year</code> specified above).
|
||||||
|
@return {number} The day of the year: 1 to days per year.
|
||||||
|
@throws Error if an invalid date or a different calendar is used.
|
||||||
|
@example var doy = calendar.dayOfYear(date)
|
||||||
|
var doy = calendar.dayOfYear(2014, 7, 1) */
|
||||||
|
dayOfYear: function(year, month, day) {
|
||||||
|
var date = this._validate(year, month, day,
|
||||||
|
$.calendars.local.invalidDate || $.calendars.regionalOptions[''].invalidDate);
|
||||||
|
return date.toJD() - this.newDate(date.year(),
|
||||||
|
this.fromMonthOfYear(date.year(), this.minMonth), this.minDay).toJD() + 1;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Retrieve the number of days in a week.
|
||||||
|
@memberof BaseCalendar
|
||||||
|
@return {number} The number of days.
|
||||||
|
@example var days = calendar.daysInWeek() */
|
||||||
|
daysInWeek: function() {
|
||||||
|
return 7;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Retrieve the day of the week for a date.
|
||||||
|
@memberof BaseCalendar
|
||||||
|
@param {CDate|number} year The date to examine or the year to examine.
|
||||||
|
@param {number} [month] The month to examine (if numeric <code>year</code> specified above).
|
||||||
|
@param {number} [day] The day to examine (if numeric <code>year</code> specified above).
|
||||||
|
@return {number} The day of the week: 0 to number of days - 1.
|
||||||
|
@throws Error if an invalid date or a different calendar is used.
|
||||||
|
@example var dow = calendar.dayOfWeek(date)
|
||||||
|
var dow = calendar.dayOfWeek(2014, 1, 26) */
|
||||||
|
dayOfWeek: function(year, month, day) {
|
||||||
|
var date = this._validate(year, month, day,
|
||||||
|
$.calendars.local.invalidDate || $.calendars.regionalOptions[''].invalidDate);
|
||||||
|
return (Math.floor(this.toJD(date)) + 2) % this.daysInWeek();
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Retrieve additional information about a date.
|
||||||
|
@memberof BaseCalendar
|
||||||
|
@param {CDate|number} year The date to examine or the year to examine.
|
||||||
|
@param {number} [month] The month to examine (if numeric <code>year</code> specified above).
|
||||||
|
@param {number} [day] The day to examine (if numeric <code>year</code> specified above).
|
||||||
|
@return {object} Additional information - content depends on calendar.
|
||||||
|
@throws Error if an invalid date or a different calendar is used.
|
||||||
|
@example var info = calendar.extraInfo(date)
|
||||||
|
var info = calendar.extraInfo(2014, 1, 26) */
|
||||||
|
extraInfo: function(year, month, day) {
|
||||||
|
this._validate(year, month, day,
|
||||||
|
$.calendars.local.invalidDate || $.calendars.regionalOptions[''].invalidDate);
|
||||||
|
return {};
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Add period(s) to a date.
|
||||||
|
Cater for no year zero.
|
||||||
|
@memberof BaseCalendar
|
||||||
|
@param {CDate} date The starting date.
|
||||||
|
@param {number} offset The number of periods to adjust by.
|
||||||
|
@param {string} period One of 'y' for years, 'm' for months, 'w' for weeks, 'd' for days.
|
||||||
|
@return {CDate} The updated date.
|
||||||
|
@throws Error if a different calendar is used.
|
||||||
|
@example calendar.add(date, 10, 'd') */
|
||||||
|
add: function(date, offset, period) {
|
||||||
|
this._validate(date, this.minMonth, this.minDay,
|
||||||
|
$.calendars.local.invalidDate || $.calendars.regionalOptions[''].invalidDate);
|
||||||
|
return this._correctAdd(date, this._add(date, offset, period), offset, period);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Add period(s) to a date.
|
||||||
|
@memberof BaseCalendar
|
||||||
|
@private
|
||||||
|
@param {CDate} date The starting date.
|
||||||
|
@param {number} offset The number of periods to adjust by.
|
||||||
|
@param {string} period One of 'y' for years, 'm' for months, 'w' for weeks, 'd' for days.
|
||||||
|
@return {number[]} The updated date as year, month, and day. */
|
||||||
|
_add: function(date, offset, period) {
|
||||||
|
this._validateLevel++;
|
||||||
|
var d;
|
||||||
|
if (period === 'd' || period === 'w') {
|
||||||
|
var jd = date.toJD() + offset * (period === 'w' ? this.daysInWeek() : 1);
|
||||||
|
d = date.calendar().fromJD(jd);
|
||||||
|
this._validateLevel--;
|
||||||
|
return [d.year(), d.month(), d.day()];
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
var y = date.year() + (period === 'y' ? offset : 0);
|
||||||
|
var m = date.monthOfYear() + (period === 'm' ? offset : 0);
|
||||||
|
d = date.day();
|
||||||
|
var resyncYearMonth = function(calendar) {
|
||||||
|
while (m < calendar.minMonth) {
|
||||||
|
y--;
|
||||||
|
m += calendar.monthsInYear(y);
|
||||||
|
}
|
||||||
|
var yearMonths = calendar.monthsInYear(y);
|
||||||
|
while (m > yearMonths - 1 + calendar.minMonth) {
|
||||||
|
y++;
|
||||||
|
m -= yearMonths;
|
||||||
|
yearMonths = calendar.monthsInYear(y);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (period === 'y') {
|
||||||
|
if (date.month() !== this.fromMonthOfYear(y, m)) { // Hebrew
|
||||||
|
m = this.newDate(y, date.month(), this.minDay).monthOfYear();
|
||||||
|
}
|
||||||
|
m = Math.min(m, this.monthsInYear(y));
|
||||||
|
d = Math.min(d, this.daysInMonth(y, this.fromMonthOfYear(y, m)));
|
||||||
|
}
|
||||||
|
else if (period === 'm') {
|
||||||
|
resyncYearMonth(this);
|
||||||
|
d = Math.min(d, this.daysInMonth(y, this.fromMonthOfYear(y, m)));
|
||||||
|
}
|
||||||
|
var ymd = [y, this.fromMonthOfYear(y, m), d];
|
||||||
|
this._validateLevel--;
|
||||||
|
return ymd;
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
this._validateLevel--;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Correct a candidate date after adding period(s) to a date.
|
||||||
|
Handle no year zero if necessary.
|
||||||
|
@memberof BaseCalendar
|
||||||
|
@private
|
||||||
|
@param {CDate} date The starting date.
|
||||||
|
@param {number[]} ymd The added date.
|
||||||
|
@param {number} offset The number of periods to adjust by.
|
||||||
|
@param {string} period One of 'y' for years, 'm' for months, 'w' for weeks, 'd' for days.
|
||||||
|
@return {CDate} The updated date. */
|
||||||
|
_correctAdd: function(date, ymd, offset, period) {
|
||||||
|
if (!this.hasYearZero && (period === 'y' || period === 'm')) {
|
||||||
|
if (ymd[0] === 0 || // In year zero
|
||||||
|
(date.year() > 0) !== (ymd[0] > 0)) { // Crossed year zero
|
||||||
|
var adj = {y: [1, 1, 'y'], m: [1, this.monthsInYear(-1), 'm'],
|
||||||
|
w: [this.daysInWeek(), this.daysInYear(-1), 'd'],
|
||||||
|
d: [1, this.daysInYear(-1), 'd']}[period];
|
||||||
|
var dir = (offset < 0 ? -1 : +1);
|
||||||
|
ymd = this._add(date, offset * adj[0] + dir * adj[1], adj[2]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return date.date(ymd[0], ymd[1], ymd[2]);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Set a portion of the date.
|
||||||
|
@memberof BaseCalendar
|
||||||
|
@param {CDate} date The starting date.
|
||||||
|
@param {number} value The new value for the period.
|
||||||
|
@param {string} period One of 'y' for year, 'm' for month, 'd' for day.
|
||||||
|
@return {CDate} The updated date.
|
||||||
|
@throws Error if an invalid date or a different calendar is used.
|
||||||
|
@example calendar.set(date, 10, 'd') */
|
||||||
|
set: function(date, value, period) {
|
||||||
|
this._validate(date, this.minMonth, this.minDay,
|
||||||
|
$.calendars.local.invalidDate || $.calendars.regionalOptions[''].invalidDate);
|
||||||
|
var y = (period === 'y' ? value : date.year());
|
||||||
|
var m = (period === 'm' ? value : date.month());
|
||||||
|
var d = (period === 'd' ? value : date.day());
|
||||||
|
if (period === 'y' || period === 'm') {
|
||||||
|
d = Math.min(d, this.daysInMonth(y, m));
|
||||||
|
}
|
||||||
|
return date.date(y, m, d);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Determine whether a date is valid for this calendar.
|
||||||
|
@memberof BaseCalendar
|
||||||
|
@param {number} year The year to examine.
|
||||||
|
@param {number} month The month to examine.
|
||||||
|
@param {number} day The day to examine.
|
||||||
|
@return {boolean} <code>true</code> if a valid date, <code>false</code> if not.
|
||||||
|
@example if (calendar.isValid(2014, 2, 31)) ... */
|
||||||
|
isValid: function(year, month, day) {
|
||||||
|
this._validateLevel++;
|
||||||
|
var valid = (this.hasYearZero || year !== 0);
|
||||||
|
if (valid) {
|
||||||
|
var date = this.newDate(year, month, this.minDay);
|
||||||
|
valid = (month >= this.minMonth && month - this.minMonth < this.monthsInYear(date)) &&
|
||||||
|
(day >= this.minDay && day - this.minDay < this.daysInMonth(date));
|
||||||
|
}
|
||||||
|
this._validateLevel--;
|
||||||
|
return valid;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Convert the date to a standard (Gregorian) JavaScript Date.
|
||||||
|
@memberof BaseCalendar
|
||||||
|
@param {CDate|number} year The date to convert or the year to convert.
|
||||||
|
@param {number} [month] The month to convert (if numeric <code>year</code> specified above).
|
||||||
|
@param {number} [day] The day to convert (if numeric <code>year</code> specified above).
|
||||||
|
@return {Date} The equivalent JavaScript date.
|
||||||
|
@throws Error if an invalid date or a different calendar is used.
|
||||||
|
@example var jsd = calendar.toJSDate(date)
|
||||||
|
var jsd = calendar.toJSDate(2014, 1, 26) */
|
||||||
|
toJSDate: function(year, month, day) {
|
||||||
|
var date = this._validate(year, month, day,
|
||||||
|
$.calendars.local.invalidDate || $.calendars.regionalOptions[''].invalidDate);
|
||||||
|
return $.calendars.instance().fromJD(this.toJD(date)).toJSDate();
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Convert the date from a standard (Gregorian) JavaScript Date.
|
||||||
|
@memberof BaseCalendar
|
||||||
|
@param {Date} jsd The JavaScript date.
|
||||||
|
@return {CDate} The equivalent calendar date.
|
||||||
|
@example var date = calendar.fromJSDate(jsd) */
|
||||||
|
fromJSDate: function(jsd) {
|
||||||
|
return this.fromJD($.calendars.instance().fromJSDate(jsd).toJD());
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Check that a candidate date is from the same calendar and is valid.
|
||||||
|
@memberof BaseCalendar
|
||||||
|
@private
|
||||||
|
@param {CDate|number} year The date to validate or the year to validate.
|
||||||
|
@param {number} [month] The month to validate (if numeric <code>year</code> specified above).
|
||||||
|
@param {number} [day] The day to validate (if numeric <code>year</code> specified above).
|
||||||
|
@param {string} error Error message if invalid.
|
||||||
|
@throws Error if an invalid date or a different calendar is used. */
|
||||||
|
_validate: function(year, month, day, error) {
|
||||||
|
if (year.year) {
|
||||||
|
if (this._validateLevel === 0 && this.name !== year.calendar().name) {
|
||||||
|
throw ($.calendars.local.differentCalendars || $.calendars.regionalOptions[''].differentCalendars).
|
||||||
|
replace(/\{0\}/, this.local.name).replace(/\{1\}/, year.calendar().local.name);
|
||||||
|
}
|
||||||
|
return year;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
this._validateLevel++;
|
||||||
|
if (this._validateLevel === 1 && !this.isValid(year, month, day)) {
|
||||||
|
throw error.replace(/\{0\}/, this.local.name);
|
||||||
|
}
|
||||||
|
var date = this.newDate(year, month, day);
|
||||||
|
this._validateLevel--;
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
this._validateLevel--;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Implementation of the Proleptic Gregorian Calendar.
|
||||||
|
See <a href=":http://en.wikipedia.org/wiki/Gregorian_calendar">http://en.wikipedia.org/wiki/Gregorian_calendar</a>
|
||||||
|
and <a href="http://en.wikipedia.org/wiki/Proleptic_Gregorian_calendar">http://en.wikipedia.org/wiki/Proleptic_Gregorian_calendar</a>.
|
||||||
|
@class GregorianCalendar
|
||||||
|
@augments BaseCalendar
|
||||||
|
@param {string} [language=''] The language code (default English) for localisation. */
|
||||||
|
function GregorianCalendar(language) {
|
||||||
|
this.local = this.regionalOptions[language] || this.regionalOptions[''];
|
||||||
|
}
|
||||||
|
|
||||||
|
GregorianCalendar.prototype = new BaseCalendar();
|
||||||
|
|
||||||
|
$.extend(GregorianCalendar.prototype, {
|
||||||
|
/** The calendar name.
|
||||||
|
@memberof GregorianCalendar */
|
||||||
|
name: 'Gregorian',
|
||||||
|
/** Julian date of start of Gregorian epoch: 1 January 0001 CE.
|
||||||
|
@memberof GregorianCalendar */
|
||||||
|
jdEpoch: 1721425.5,
|
||||||
|
/** Days per month in a common year.
|
||||||
|
@memberof GregorianCalendar */
|
||||||
|
daysPerMonth: [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31],
|
||||||
|
/** <code>true</code> if has a year zero, <code>false</code> if not.
|
||||||
|
@memberof GregorianCalendar */
|
||||||
|
hasYearZero: false,
|
||||||
|
/** The minimum month number.
|
||||||
|
@memberof GregorianCalendar */
|
||||||
|
minMonth: 1,
|
||||||
|
/** The first month in the year.
|
||||||
|
@memberof GregorianCalendar */
|
||||||
|
firstMonth: 1,
|
||||||
|
/** The minimum day number.
|
||||||
|
@memberof GregorianCalendar */
|
||||||
|
minDay: 1,
|
||||||
|
|
||||||
|
/** Convert a number into a localised form.
|
||||||
|
@callback CalendarsDigits
|
||||||
|
@param {number} value The number to convert.
|
||||||
|
@return {string} The localised number.
|
||||||
|
@example digits: $.calendars.substituteDigits(['۰', '۱', '۲', '۳', '۴', '۵', '۶', '۷', '۸', '۹']) */
|
||||||
|
|
||||||
|
/** Localisations for the plugin.
|
||||||
|
Entries are objects indexed by the language code ('' being the default US/English).
|
||||||
|
Each object has the following attributes.
|
||||||
|
@memberof GregorianCalendar
|
||||||
|
@property {string} [name='Gregorian'] The calendar name.
|
||||||
|
@property {string[]} [epochs=['BCE','CE']] The epoch names.
|
||||||
|
@property {string[]} [monthNames=[...]] The long names of the months of the year.
|
||||||
|
@property {string[]} [monthNamesShort=[...]] The short names of the months of the year.
|
||||||
|
@property {string[]} [dayNames=[...]] The long names of the days of the week.
|
||||||
|
@property {string[]} [dayNamesShort=[...]] The short names of the days of the week.
|
||||||
|
@property {string[]} [dayNamesMin=[...]] The minimal names of the days of the week.
|
||||||
|
@property {CalendarsDigits} [digits=null] Convert numbers to localised versions.
|
||||||
|
@property {string} [dateFormat='mm/dd/yyyy'] The date format for this calendar.
|
||||||
|
See the options on {@linkcode BaseCalendar.formatDate|formatDate} for details.
|
||||||
|
@property {number} [firstDay=0] The number of the first day of the week, starting at 0.
|
||||||
|
@property {boolean} [isRTL=false] <code>true</code> if this localisation reads right-to-left. */
|
||||||
|
regionalOptions: { // Localisations
|
||||||
|
'': {
|
||||||
|
name: 'Gregorian',
|
||||||
|
epochs: ['BCE', 'CE'],
|
||||||
|
monthNames: ['January', 'February', 'March', 'April', 'May', 'June',
|
||||||
|
'July', 'August', 'September', 'October', 'November', 'December'],
|
||||||
|
monthNamesShort: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'],
|
||||||
|
dayNames: ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'],
|
||||||
|
dayNamesShort: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'],
|
||||||
|
dayNamesMin: ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'],
|
||||||
|
digits: null,
|
||||||
|
dateFormat: 'mm/dd/yyyy',
|
||||||
|
firstDay: 0,
|
||||||
|
isRTL: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Determine whether this date is in a leap year.
|
||||||
|
@memberof GregorianCalendar
|
||||||
|
@param {CDate|number} year The date to examine or the year to examine.
|
||||||
|
@return {boolean} <code>true</code> if this is a leap year, <code>false</code> if not.
|
||||||
|
@throws Error if an invalid year or a different calendar is used. */
|
||||||
|
leapYear: function(year) {
|
||||||
|
var date = this._validate(year, this.minMonth, this.minDay,
|
||||||
|
$.calendars.local.invalidYear || $.calendars.regionalOptions[''].invalidYear);
|
||||||
|
year = date.year() + (date.year() < 0 ? 1 : 0); // No year zero
|
||||||
|
return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Determine the week of the year for a date - ISO 8601.
|
||||||
|
@memberof GregorianCalendar
|
||||||
|
@param {CDate|number} year The date to examine or the year to examine.
|
||||||
|
@param {number} [month] The month to examine (if numeric <code>year</code> specified above).
|
||||||
|
@param {number} [day] The day to examine (if numeric <code>year</code> specified above).
|
||||||
|
@return {number} The week of the year, starting from 1.
|
||||||
|
@throws Error if an invalid date or a different calendar is used. */
|
||||||
|
weekOfYear: function(year, month, day) {
|
||||||
|
// Find Thursday of this week starting on Monday
|
||||||
|
var checkDate = this.newDate(year, month, day);
|
||||||
|
checkDate.add(4 - (checkDate.dayOfWeek() || 7), 'd');
|
||||||
|
return Math.floor((checkDate.dayOfYear() - 1) / 7) + 1;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Retrieve the number of days in a month.
|
||||||
|
@memberof GregorianCalendar
|
||||||
|
@param {CDate|number} year The date to examine or the year of the month.
|
||||||
|
@param {number} [month] The month (if numeric <code>year</code> specified above).
|
||||||
|
@return {number} The number of days in this month.
|
||||||
|
@throws Error if an invalid month/year or a different calendar is used. */
|
||||||
|
daysInMonth: function(year, month) {
|
||||||
|
var date = this._validate(year, month, this.minDay,
|
||||||
|
$.calendars.local.invalidMonth || $.calendars.regionalOptions[''].invalidMonth);
|
||||||
|
return this.daysPerMonth[date.month() - 1] +
|
||||||
|
(date.month() === 2 && this.leapYear(date.year()) ? 1 : 0);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Determine whether this date is a week day.
|
||||||
|
@memberof GregorianCalendar
|
||||||
|
@param {CDate|number} year The date to examine or the year to examine.
|
||||||
|
@param {number} [month] The month to examine (if numeric <code>year</code> specified above).
|
||||||
|
@param {number} [day] The day to examine (if numeric <code>year</code> specified above).
|
||||||
|
@return {boolean} <code>true</code> if a week day, <code>false</code> if not.
|
||||||
|
@throws Error if an invalid date or a different calendar is used. */
|
||||||
|
weekDay: function(year, month, day) {
|
||||||
|
return (this.dayOfWeek(year, month, day) || 7) < 6;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Retrieve the Julian date equivalent for this date,
|
||||||
|
i.e. days since January 1, 4713 BCE Greenwich noon.
|
||||||
|
@memberof GregorianCalendar
|
||||||
|
@param {CDate|number} year The date to convert or the year to convert.
|
||||||
|
@param {number} [month] The month to convert (if numeric <code>year</code> specified above).
|
||||||
|
@param {number} [day] The day to convert (if numeric <code>year</code> specified above).
|
||||||
|
@return {number} The equivalent Julian date.
|
||||||
|
@throws Error if an invalid date or a different calendar is used. */
|
||||||
|
toJD: function(year, month, day) {
|
||||||
|
var date = this._validate(year, month, day,
|
||||||
|
$.calendars.local.invalidDate || $.calendars.regionalOptions[''].invalidDate);
|
||||||
|
year = date.year();
|
||||||
|
month = date.month();
|
||||||
|
day = date.day();
|
||||||
|
if (year < 0) { year++; } // No year zero
|
||||||
|
// Jean Meeus algorithm, "Astronomical Algorithms", 1991
|
||||||
|
if (month < 3) {
|
||||||
|
month += 12;
|
||||||
|
year--;
|
||||||
|
}
|
||||||
|
var a = Math.floor(year / 100);
|
||||||
|
var b = 2 - a + Math.floor(a / 4);
|
||||||
|
return Math.floor(365.25 * (year + 4716)) +
|
||||||
|
Math.floor(30.6001 * (month + 1)) + day + b - 1524.5;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Create a new date from a Julian date.
|
||||||
|
@memberof GregorianCalendar
|
||||||
|
@param {number} jd The Julian date to convert.
|
||||||
|
@return {CDate} The equivalent date. */
|
||||||
|
fromJD: function(jd) {
|
||||||
|
// Jean Meeus algorithm, "Astronomical Algorithms", 1991
|
||||||
|
var z = Math.floor(jd + 0.5);
|
||||||
|
var a = Math.floor((z - 1867216.25) / 36524.25);
|
||||||
|
a = z + 1 + a - Math.floor(a / 4);
|
||||||
|
var b = a + 1524;
|
||||||
|
var c = Math.floor((b - 122.1) / 365.25);
|
||||||
|
var d = Math.floor(365.25 * c);
|
||||||
|
var e = Math.floor((b - d) / 30.6001);
|
||||||
|
var day = b - d - Math.floor(e * 30.6001);
|
||||||
|
var month = e - (e > 13.5 ? 13 : 1);
|
||||||
|
var year = c - (month > 2.5 ? 4716 : 4715);
|
||||||
|
if (year <= 0) { year--; } // No year zero
|
||||||
|
return this.newDate(year, month, day);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Convert this date to a standard (Gregorian) JavaScript Date.
|
||||||
|
@memberof GregorianCalendar
|
||||||
|
@param {CDate|number} year The date to convert or the year to convert.
|
||||||
|
@param {number} [month] The month to convert (if numeric <code>year</code> specified above).
|
||||||
|
@param {number} [day] The day to convert (if numeric <code>year</code> specified above).
|
||||||
|
@return {Date} The equivalent JavaScript date.
|
||||||
|
@throws Error if an invalid date or a different calendar is used. */
|
||||||
|
toJSDate: function(year, month, day) {
|
||||||
|
var date = this._validate(year, month, day,
|
||||||
|
$.calendars.local.invalidDate || $.calendars.regionalOptions[''].invalidDate);
|
||||||
|
var jsd = new Date(date.year(), date.month() - 1, date.day());
|
||||||
|
jsd.setHours(0);
|
||||||
|
jsd.setMinutes(0);
|
||||||
|
jsd.setSeconds(0);
|
||||||
|
jsd.setMilliseconds(0);
|
||||||
|
// Hours may be non-zero on daylight saving cut-over:
|
||||||
|
// > 12 when midnight changeover, but then cannot generate
|
||||||
|
// midnight datetime, so jump to 1AM, otherwise reset.
|
||||||
|
jsd.setHours(jsd.getHours() > 12 ? jsd.getHours() + 2 : 0);
|
||||||
|
return jsd;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Create a new date from a standard (Gregorian) JavaScript Date.
|
||||||
|
@memberof GregorianCalendar
|
||||||
|
@param {Date} jsd The JavaScript date to convert.
|
||||||
|
@return {CDate} The equivalent date. */
|
||||||
|
fromJSDate: function(jsd) {
|
||||||
|
return this.newDate(jsd.getFullYear(), jsd.getMonth() + 1, jsd.getDate());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Singleton manager
|
||||||
|
$.calendars = new Calendars();
|
||||||
|
|
||||||
|
// Date template
|
||||||
|
$.calendars.cdate = CDate;
|
||||||
|
|
||||||
|
// Base calendar template
|
||||||
|
$.calendars.baseCalendar = BaseCalendar;
|
||||||
|
|
||||||
|
// Gregorian calendar implementation
|
||||||
|
$.calendars.calendars.gregorian = GregorianCalendar;
|
||||||
|
|
||||||
|
})(jQuery);
|
||||||
@@ -0,0 +1,463 @@
|
|||||||
|
/* http://keith-wood.name/calendars.html
|
||||||
|
Calendars extras for jQuery v2.1.0.
|
||||||
|
Written by Keith Wood (wood.keith{at}optusnet.com.au) August 2009.
|
||||||
|
Available under the MIT (http://keith-wood.name/licence.html) license.
|
||||||
|
Please attribute the author if you use it. */
|
||||||
|
|
||||||
|
(function($) { // Hide scope, no $ conflict
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
$.extend($.calendars.regionalOptions[''], {
|
||||||
|
invalidArguments: 'Invalid arguments',
|
||||||
|
invalidFormat: 'Cannot format a date from another calendar',
|
||||||
|
missingNumberAt: 'Missing number at position {0}',
|
||||||
|
unknownNameAt: 'Unknown name at position {0}',
|
||||||
|
unexpectedLiteralAt: 'Unexpected literal at position {0}',
|
||||||
|
unexpectedText: 'Additional text found at end'
|
||||||
|
});
|
||||||
|
$.calendars.local = $.calendars.regionalOptions[''];
|
||||||
|
|
||||||
|
$.extend($.calendars.cdate.prototype, {
|
||||||
|
|
||||||
|
/** Format this date.
|
||||||
|
Found in the <code>jquery.calendars.plus.js</code> module.
|
||||||
|
@memberof CDate
|
||||||
|
@param {string} [format] The date format to use (see {@linkcode BaseCalendar.formatDate|formatDate}).
|
||||||
|
@param {object} [settings] Options for the <code>formatDate</code> function.
|
||||||
|
@return {string} The formatted date. */
|
||||||
|
formatDate: function(format, settings) {
|
||||||
|
if (typeof format !== 'string') {
|
||||||
|
settings = format;
|
||||||
|
format = '';
|
||||||
|
}
|
||||||
|
return this._calendar.formatDate(format || '', this, settings);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$.extend($.calendars.baseCalendar.prototype, {
|
||||||
|
|
||||||
|
UNIX_EPOCH: $.calendars.instance().newDate(1970, 1, 1).toJD(),
|
||||||
|
SECS_PER_DAY: 24 * 60 * 60,
|
||||||
|
TICKS_EPOCH: $.calendars.instance().jdEpoch, // 1 January 0001 CE
|
||||||
|
TICKS_PER_DAY: 24 * 60 * 60 * 10000000,
|
||||||
|
|
||||||
|
/** Date format for ATOM (RFC 3339/ISO 8601) - 'yyyy-mm-dd'.
|
||||||
|
@memberof BaseCalendar */
|
||||||
|
ATOM: 'yyyy-mm-dd',
|
||||||
|
/** Date format for cookies - 'D, dd M yyyy'.
|
||||||
|
@memberof BaseCalendar */
|
||||||
|
COOKIE: 'D, dd M yyyy',
|
||||||
|
/** Date format for the full date - 'DD, MM d, yyyy'.
|
||||||
|
@memberof BaseCalendar */
|
||||||
|
FULL: 'DD, MM d, yyyy',
|
||||||
|
/** Date format for ISO 8601 - 'yyyy-mm-dd'.
|
||||||
|
@memberof BaseCalendar */
|
||||||
|
ISO_8601: 'yyyy-mm-dd',
|
||||||
|
/** Date format for Julian date - days since January 1, 4713 BCE Greenwich noon.
|
||||||
|
@memberof BaseCalendar */
|
||||||
|
JULIAN: 'J',
|
||||||
|
/** Date format for RFC 822 - 'D, d M yy'.
|
||||||
|
@memberof BaseCalendar */
|
||||||
|
RFC_822: 'D, d M yy',
|
||||||
|
/** Date format for RFC 850 - 'DD, dd-M-yy'.
|
||||||
|
@memberof BaseCalendar */
|
||||||
|
RFC_850: 'DD, dd-M-yy',
|
||||||
|
/** Date format for RFC 1036 - 'D, d M yy'.
|
||||||
|
@memberof BaseCalendar */
|
||||||
|
RFC_1036: 'D, d M yy',
|
||||||
|
/** Date format for RFC 1123 - 'D, d M yyyy'.
|
||||||
|
@memberof BaseCalendar */
|
||||||
|
RFC_1123: 'D, d M yyyy',
|
||||||
|
/** Date format for RFC 2822 - 'D, d M yyyy'.
|
||||||
|
@memberof BaseCalendar */
|
||||||
|
RFC_2822: 'D, d M yyyy',
|
||||||
|
/** Date format for RSS (RFC 822) - 'D, d M yy'.
|
||||||
|
@memberof BaseCalendar */
|
||||||
|
RSS: 'D, d M yy',
|
||||||
|
/** Date format for Windows ticks - number of 100-nanosecond ticks since 1 January 0001 00:00:00 UTC.
|
||||||
|
@memberof BaseCalendar */
|
||||||
|
TICKS: '!',
|
||||||
|
/** Date format for Unix timestamp - number of seconds elapsed since the
|
||||||
|
start of the Unix epoch at 1 January 1970 00:00:00 UTC.
|
||||||
|
@memberof BaseCalendar */
|
||||||
|
TIMESTAMP: '@',
|
||||||
|
/** Date format for W3C (ISO 8601) - 'yyyy-mm-dd'.
|
||||||
|
@memberof BaseCalendar */
|
||||||
|
W3C: 'yyyy-mm-dd',
|
||||||
|
|
||||||
|
/** Format a date object into a string value.
|
||||||
|
The format can be combinations of the following:
|
||||||
|
<ul>
|
||||||
|
<li>d - day of month (no leading zero)</li>
|
||||||
|
<li>dd - day of month (two digit)</li>
|
||||||
|
<li>o - day of year (no leading zeros)</li>
|
||||||
|
<li>oo - day of year (three digit)</li>
|
||||||
|
<li>D - day name short</li>
|
||||||
|
<li>DD - day name long</li>
|
||||||
|
<li>w - week of year (no leading zero)</li>
|
||||||
|
<li>ww - week of year (two digit)</li>
|
||||||
|
<li>m - month of year (no leading zero)</li>
|
||||||
|
<li>mm - month of year (two digit)</li>
|
||||||
|
<li>M - month name short</li>
|
||||||
|
<li>MM - month name long</li>
|
||||||
|
<li>yy - year (two digit)</li>
|
||||||
|
<li>yyyy - year (four digit)</li>
|
||||||
|
<li>YYYY - formatted year</li>
|
||||||
|
<li>J - Julian date (days since January 1, 4713 BCE Greenwich noon)</li>
|
||||||
|
<li>@ - Unix timestamp (s since 01/01/1970)</li>
|
||||||
|
<li>! - Windows ticks (100ns since 01/01/0001)</li>
|
||||||
|
<li>'...' - literal text</li>
|
||||||
|
<li>'' - single quote</li>
|
||||||
|
</ul>
|
||||||
|
Found in the <code>jquery.calendars.plus.js</code> module.
|
||||||
|
@memberof BaseCalendar
|
||||||
|
@param {string} [format] The desired format of the date (defaults to calendar format).
|
||||||
|
@param {CDate} date The date value to format.
|
||||||
|
@param {object} [settings] Addition options, whose attributes include:
|
||||||
|
@param {string[]} [settings.dayNamesShort] Abbreviated names of the days from day 0 (Sunday).
|
||||||
|
@param {string[]} [settings.dayNames] Names of the days from day 0 (Sunday).
|
||||||
|
@param {string[]} [settings.monthNamesShort] Abbreviated names of the months.
|
||||||
|
@param {string[]} [settings.monthNames] Names of the months.
|
||||||
|
@param {boolean} [settings.localNumbers=false] <code>true</code> to localise numbers (if available),
|
||||||
|
<code>false</code> to use normal Arabic numerals.
|
||||||
|
@return {string} The date in the above format.
|
||||||
|
@throws Errors if the date is from a different calendar. */
|
||||||
|
formatDate: function(format, date, settings) {
|
||||||
|
if (typeof format !== 'string') {
|
||||||
|
settings = date;
|
||||||
|
date = format;
|
||||||
|
format = '';
|
||||||
|
}
|
||||||
|
if (!date) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (date.calendar() !== this) {
|
||||||
|
throw $.calendars.local.invalidFormat || $.calendars.regionalOptions[''].invalidFormat;
|
||||||
|
}
|
||||||
|
format = format || this.local.dateFormat;
|
||||||
|
settings = settings || {};
|
||||||
|
var dayNamesShort = settings.dayNamesShort || this.local.dayNamesShort;
|
||||||
|
var dayNames = settings.dayNames || this.local.dayNames;
|
||||||
|
var monthNamesShort = settings.monthNamesShort || this.local.monthNamesShort;
|
||||||
|
var monthNames = settings.monthNames || this.local.monthNames;
|
||||||
|
var localNumbers = settings.localNumbers || this.local.localNumbers;
|
||||||
|
// Check whether a format character is doubled
|
||||||
|
var doubled = function(match, step) {
|
||||||
|
var matches = 1;
|
||||||
|
while (iFormat + matches < format.length && format.charAt(iFormat + matches) === match) {
|
||||||
|
matches++;
|
||||||
|
}
|
||||||
|
iFormat += matches - 1;
|
||||||
|
return Math.floor(matches / (step || 1)) > 1;
|
||||||
|
};
|
||||||
|
// Format a number, with leading zeroes if necessary
|
||||||
|
var formatNumber = function(match, value, len, step) {
|
||||||
|
var num = '' + value;
|
||||||
|
if (doubled(match, step)) {
|
||||||
|
while (num.length < len) {
|
||||||
|
num = '0' + num;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return num;
|
||||||
|
};
|
||||||
|
// Format a name, short or long as requested
|
||||||
|
var formatName = function(match, value, shortNames, longNames) {
|
||||||
|
return (doubled(match) ? longNames[value] : shortNames[value]);
|
||||||
|
};
|
||||||
|
// Localise numbers if requested and available
|
||||||
|
var localiseNumbers = localNumbers && this.local.digits ?
|
||||||
|
this.local.digits : function(value) { return value; };
|
||||||
|
var output = '';
|
||||||
|
var literal = false;
|
||||||
|
for (var iFormat = 0; iFormat < format.length; iFormat++) {
|
||||||
|
if (literal) {
|
||||||
|
if (format.charAt(iFormat) === '\'' && !doubled('\'')) {
|
||||||
|
literal = false;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
output += format.charAt(iFormat);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
switch (format.charAt(iFormat)) {
|
||||||
|
case 'd':
|
||||||
|
output += localiseNumbers(formatNumber('d', date.day(), 2));
|
||||||
|
break;
|
||||||
|
case 'D':
|
||||||
|
output += formatName('D', date.dayOfWeek(), dayNamesShort, dayNames);
|
||||||
|
break;
|
||||||
|
case 'o':
|
||||||
|
output += formatNumber('o', date.dayOfYear(), 3);
|
||||||
|
break;
|
||||||
|
case 'w':
|
||||||
|
output += formatNumber('w', date.weekOfYear(), 2);
|
||||||
|
break;
|
||||||
|
case 'm':
|
||||||
|
output += localiseNumbers(formatNumber('m', date.month(), 2));
|
||||||
|
break;
|
||||||
|
case 'M':
|
||||||
|
output += formatName('M', date.month() - this.minMonth, monthNamesShort, monthNames);
|
||||||
|
break;
|
||||||
|
case 'y':
|
||||||
|
output += localiseNumbers(doubled('y', 2) ? date.year() :
|
||||||
|
(date.year() % 100 < 10 ? '0' : '') + date.year() % 100);
|
||||||
|
break;
|
||||||
|
case 'Y':
|
||||||
|
doubled('Y', 2);
|
||||||
|
output += date.formatYear();
|
||||||
|
break;
|
||||||
|
case 'J':
|
||||||
|
output += date.toJD();
|
||||||
|
break;
|
||||||
|
case '@':
|
||||||
|
output += (date.toJD() - this.UNIX_EPOCH) * this.SECS_PER_DAY;
|
||||||
|
break;
|
||||||
|
case '!':
|
||||||
|
output += (date.toJD() - this.TICKS_EPOCH) * this.TICKS_PER_DAY;
|
||||||
|
break;
|
||||||
|
case '\'':
|
||||||
|
if (doubled('\'')) {
|
||||||
|
output += '\'';
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
literal = true;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
output += format.charAt(iFormat);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Parse a string value into a date object.
|
||||||
|
See {@linkcode BaseCalendar.formatDate|formatDate} for the possible formats, plus:
|
||||||
|
<ul>
|
||||||
|
<li>* - ignore rest of string</li>
|
||||||
|
</ul>
|
||||||
|
Found in the <code>jquery.calendars.plus.js</code> module.
|
||||||
|
@memberof BaseCalendar
|
||||||
|
@param {string} format The expected format of the date ('' for default calendar format).
|
||||||
|
@param {string} value The date in the above format.
|
||||||
|
@param {object} [settings] Additional options whose attributes include:
|
||||||
|
@param {number} [settings.shortYearCutoff] The cutoff year for determining the century.
|
||||||
|
@param {string[]} [settings.dayNamesShort] Abbreviated names of the days from day 0 (Sunday).
|
||||||
|
@param {string[]} [settings.dayNames] Names of the days from day 0 (Sunday).
|
||||||
|
@param {string[]} [settings.monthNamesShort] Abbreviated names of the months.
|
||||||
|
@param {string[]} [settings.monthNames] Names of the months.
|
||||||
|
@return {CDate} The extracted date value or <code>null</code> if value is blank.
|
||||||
|
@throws Errors if the format and/or value are missing,
|
||||||
|
if the value doesn't match the format, or if the date is invalid. */
|
||||||
|
parseDate: function(format, value, settings) {
|
||||||
|
if (typeof value === 'undefined' || value === null) {
|
||||||
|
throw $.calendars.local.invalidArguments || $.calendars.regionalOptions[''].invalidArguments;
|
||||||
|
}
|
||||||
|
value = (typeof value === 'object' ? value.toString() : value + '');
|
||||||
|
if (value === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
format = format || this.local.dateFormat;
|
||||||
|
settings = settings || {};
|
||||||
|
var shortYearCutoff = settings.shortYearCutoff || this.shortYearCutoff;
|
||||||
|
shortYearCutoff = (typeof shortYearCutoff !== 'string' ? shortYearCutoff :
|
||||||
|
this.today().year() % 100 + parseInt(shortYearCutoff, 10));
|
||||||
|
var dayNamesShort = settings.dayNamesShort || this.local.dayNamesShort;
|
||||||
|
var dayNames = settings.dayNames || this.local.dayNames;
|
||||||
|
var monthNamesShort = settings.monthNamesShort || this.local.monthNamesShort;
|
||||||
|
var monthNames = settings.monthNames || this.local.monthNames;
|
||||||
|
var jd = -1;
|
||||||
|
var year = -1;
|
||||||
|
var month = -1;
|
||||||
|
var day = -1;
|
||||||
|
var doy = -1;
|
||||||
|
var shortYear = false;
|
||||||
|
var literal = false;
|
||||||
|
// Check whether a format character is doubled
|
||||||
|
var doubled = function(match, step) {
|
||||||
|
var matches = 1;
|
||||||
|
while (iFormat + matches < format.length && format.charAt(iFormat + matches) === match) {
|
||||||
|
matches++;
|
||||||
|
}
|
||||||
|
iFormat += matches - 1;
|
||||||
|
return Math.floor(matches / (step || 1)) > 1;
|
||||||
|
};
|
||||||
|
// Extract a number from the string value
|
||||||
|
var getNumber = function(match, step) {
|
||||||
|
var isDoubled = doubled(match, step);
|
||||||
|
var size = [2, 3, isDoubled ? 4 : 2, isDoubled ? 4 : 2, 10, 11, 20]['oyYJ@!'.indexOf(match) + 1];
|
||||||
|
var digits = new RegExp('^-?\\d{1,' + size + '}');
|
||||||
|
var num = value.substring(iValue).match(digits);
|
||||||
|
if (!num) {
|
||||||
|
throw ($.calendars.local.missingNumberAt || $.calendars.regionalOptions[''].missingNumberAt).
|
||||||
|
replace(/\{0\}/, iValue);
|
||||||
|
}
|
||||||
|
iValue += num[0].length;
|
||||||
|
return parseInt(num[0], 10);
|
||||||
|
};
|
||||||
|
// Extract a name from the string value and convert to an index
|
||||||
|
var calendar = this;
|
||||||
|
var getName = function(match, shortNames, longNames, step) {
|
||||||
|
var names = (doubled(match, step) ? longNames : shortNames);
|
||||||
|
for (var i = 0; i < names.length; i++) {
|
||||||
|
if (value.substr(iValue, names[i].length).toLowerCase() === names[i].toLowerCase()) {
|
||||||
|
iValue += names[i].length;
|
||||||
|
return i + calendar.minMonth;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw ($.calendars.local.unknownNameAt || $.calendars.regionalOptions[''].unknownNameAt).
|
||||||
|
replace(/\{0\}/, iValue);
|
||||||
|
};
|
||||||
|
// Confirm that a literal character matches the string value
|
||||||
|
var checkLiteral = function() {
|
||||||
|
if (value.charAt(iValue) !== format.charAt(iFormat)) {
|
||||||
|
throw ($.calendars.local.unexpectedLiteralAt ||
|
||||||
|
$.calendars.regionalOptions[''].unexpectedLiteralAt).replace(/\{0\}/, iValue);
|
||||||
|
}
|
||||||
|
iValue++;
|
||||||
|
};
|
||||||
|
var iValue = 0;
|
||||||
|
for (var iFormat = 0; iFormat < format.length; iFormat++) {
|
||||||
|
if (literal) {
|
||||||
|
if (format.charAt(iFormat) === '\'' && !doubled('\'')) {
|
||||||
|
literal = false;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
checkLiteral();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
switch (format.charAt(iFormat)) {
|
||||||
|
case 'd':
|
||||||
|
day = getNumber('d');
|
||||||
|
break;
|
||||||
|
case 'D':
|
||||||
|
getName('D', dayNamesShort, dayNames);
|
||||||
|
break;
|
||||||
|
case 'o':
|
||||||
|
doy = getNumber('o');
|
||||||
|
break;
|
||||||
|
case 'w':
|
||||||
|
getNumber('w');
|
||||||
|
break;
|
||||||
|
case 'm':
|
||||||
|
month = getNumber('m');
|
||||||
|
break;
|
||||||
|
case 'M':
|
||||||
|
month = getName('M', monthNamesShort, monthNames);
|
||||||
|
break;
|
||||||
|
case 'y':
|
||||||
|
var iSave = iFormat;
|
||||||
|
shortYear = !doubled('y', 2);
|
||||||
|
iFormat = iSave;
|
||||||
|
year = getNumber('y', 2);
|
||||||
|
break;
|
||||||
|
case 'Y':
|
||||||
|
year = getNumber('Y', 2);
|
||||||
|
break;
|
||||||
|
case 'J':
|
||||||
|
jd = getNumber('J') + 0.5;
|
||||||
|
if (value.charAt(iValue) === '.') {
|
||||||
|
iValue++;
|
||||||
|
getNumber('J');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case '@':
|
||||||
|
jd = getNumber('@') / this.SECS_PER_DAY + this.UNIX_EPOCH;
|
||||||
|
break;
|
||||||
|
case '!':
|
||||||
|
jd = getNumber('!') / this.TICKS_PER_DAY + this.TICKS_EPOCH;
|
||||||
|
break;
|
||||||
|
case '*':
|
||||||
|
iValue = value.length;
|
||||||
|
break;
|
||||||
|
case '\'':
|
||||||
|
if (doubled('\'')) {
|
||||||
|
checkLiteral();
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
literal = true;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
checkLiteral();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (iValue < value.length) {
|
||||||
|
throw $.calendars.local.unexpectedText || $.calendars.regionalOptions[''].unexpectedText;
|
||||||
|
}
|
||||||
|
if (year === -1) {
|
||||||
|
year = this.today().year();
|
||||||
|
}
|
||||||
|
else if (year < 100 && shortYear) {
|
||||||
|
year += (shortYearCutoff === -1 ? 1900 : this.today().year() -
|
||||||
|
this.today().year() % 100 - (year <= shortYearCutoff ? 0 : 100));
|
||||||
|
}
|
||||||
|
if (doy > -1) {
|
||||||
|
month = 1;
|
||||||
|
day = doy;
|
||||||
|
for (var dim = this.daysInMonth(year, month); day > dim; dim = this.daysInMonth(year, month)) {
|
||||||
|
month++;
|
||||||
|
day -= dim;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (jd > -1 ? this.fromJD(jd) : this.newDate(year, month, day));
|
||||||
|
},
|
||||||
|
|
||||||
|
/** A date may be specified as an exact value or a relative one.
|
||||||
|
Found in the <code>jquery.calendars.plus.js</code> module.
|
||||||
|
@memberof BaseCalendar
|
||||||
|
@param {CDate|number|string} dateSpec The date as an object or string in the given format or
|
||||||
|
an offset - numeric days from today, or string amounts and periods, e.g. '+1m +2w'.
|
||||||
|
@param {CDate} defaultDate The date to use if no other supplied, may be <code>null</code>.
|
||||||
|
@param {CDate} [currentDate=null] The current date as a possible basis for relative dates,
|
||||||
|
if <code>null</code> today is used.
|
||||||
|
@param {string} [dateFormat] The expected date format -
|
||||||
|
see {@linkcode BaseCalendar.formatDate|formatDate}. Use '' for the calendar default format.
|
||||||
|
@param {object} [settings] Additional options whose attributes include:
|
||||||
|
@param {number} [settings.shortYearCutoff] The cutoff year for determining the century.
|
||||||
|
@param {string[]} [settings.dayNamesShort] Abbreviated names of the days from day 0 (Sunday).
|
||||||
|
@param {string[]} [settings.dayNames] Names of the days from day 0 (Sunday).
|
||||||
|
@param {string[]} [settings.monthNamesShort] Abbreviated names of the months.
|
||||||
|
@param {string[]} [settings.monthNames] Names of the months.
|
||||||
|
@return {CDate} The decoded date. */
|
||||||
|
determineDate: function(dateSpec, defaultDate, currentDate, dateFormat, settings) {
|
||||||
|
if (currentDate && typeof currentDate !== 'object') {
|
||||||
|
settings = dateFormat;
|
||||||
|
dateFormat = currentDate;
|
||||||
|
currentDate = null;
|
||||||
|
}
|
||||||
|
if (typeof dateFormat !== 'string') {
|
||||||
|
settings = dateFormat;
|
||||||
|
dateFormat = '';
|
||||||
|
}
|
||||||
|
var calendar = this;
|
||||||
|
var offsetString = function(offset) {
|
||||||
|
try {
|
||||||
|
return calendar.parseDate(dateFormat, offset, settings);
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
offset = offset.toLowerCase();
|
||||||
|
var date = (offset.match(/^c/) && currentDate ?
|
||||||
|
currentDate.newDate() : null) || calendar.today();
|
||||||
|
var pattern = /([+-]?[0-9]+)\s*(d|w|m|y)?/g;
|
||||||
|
var matches = pattern.exec(offset);
|
||||||
|
while (matches) {
|
||||||
|
date.add(parseInt(matches[1], 10), matches[2] || 'd');
|
||||||
|
matches = pattern.exec(offset);
|
||||||
|
}
|
||||||
|
return date;
|
||||||
|
};
|
||||||
|
defaultDate = (defaultDate ? defaultDate.newDate() : null);
|
||||||
|
dateSpec = (typeof dateSpec === 'undefined' || dateSpec === null ? defaultDate :
|
||||||
|
(typeof dateSpec === 'string' ? offsetString(dateSpec) : (typeof dateSpec === 'number' ?
|
||||||
|
(isNaN(dateSpec) || dateSpec === Infinity || dateSpec === -Infinity ? defaultDate :
|
||||||
|
calendar.today().add(dateSpec, 'd')) : calendar.newDate(dateSpec))));
|
||||||
|
return dateSpec;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
})(jQuery);
|
||||||
@@ -0,0 +1,389 @@
|
|||||||
|
/* globals JQClass */
|
||||||
|
/*! Simple JavaScript Inheritance
|
||||||
|
* By John Resig http://ejohn.org/
|
||||||
|
* MIT Licensed.
|
||||||
|
*/
|
||||||
|
// Inspired by base2 and Prototype
|
||||||
|
import $ from "@/shared/common/jquery-global";
|
||||||
|
(function(){
|
||||||
|
'use strict';
|
||||||
|
var initializing = false;
|
||||||
|
|
||||||
|
// The base JQClass implementation (does nothing)
|
||||||
|
window.JQClass = function(){};
|
||||||
|
|
||||||
|
// Collection of derived classes
|
||||||
|
JQClass.classes = {};
|
||||||
|
|
||||||
|
// Create a new JQClass that inherits from this class
|
||||||
|
JQClass.extend = function extender(prop) {
|
||||||
|
var base = this.prototype;
|
||||||
|
|
||||||
|
// Instantiate a base class (but only create the instance, don't run the init constructor)
|
||||||
|
initializing = true;
|
||||||
|
var prototype = new this();
|
||||||
|
initializing = false;
|
||||||
|
|
||||||
|
// Copy the properties over onto the new prototype
|
||||||
|
for (var name in prop) { // jshint loopfunc:true
|
||||||
|
// Check if we're overwriting an existing function
|
||||||
|
if (typeof prop[name] === 'function' && typeof base[name] === 'function') {
|
||||||
|
prototype[name] = (function (name, fn) {
|
||||||
|
return function () {
|
||||||
|
var __super = this._super;
|
||||||
|
// Add a new ._super() method that is the same method but on the super-class
|
||||||
|
this._super = function (args) {
|
||||||
|
return base[name].apply(this, args || []);
|
||||||
|
};
|
||||||
|
var ret = fn.apply(this, arguments);
|
||||||
|
// The method only needs to be bound temporarily, so we remove it when we're done executing
|
||||||
|
this._super = __super;
|
||||||
|
return ret;
|
||||||
|
};
|
||||||
|
})(name, prop[name]);
|
||||||
|
// Check if we're overwriting existing default options.
|
||||||
|
} else if (typeof prop[name] === 'object' && typeof base[name] === 'object' && name === 'defaultOptions') {
|
||||||
|
var obj1 = base[name];
|
||||||
|
var obj2 = prop[name];
|
||||||
|
var obj3 = {};
|
||||||
|
var key;
|
||||||
|
for (key in obj1) { // jshint forin:false
|
||||||
|
obj3[key] = obj1[key];
|
||||||
|
}
|
||||||
|
for (key in obj2) { // jshint forin:false
|
||||||
|
obj3[key] = obj2[key];
|
||||||
|
}
|
||||||
|
prototype[name] = obj3;
|
||||||
|
} else {
|
||||||
|
prototype[name] = prop[name];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The dummy class constructor
|
||||||
|
function JQClass() {
|
||||||
|
// All construction is actually done in the init method
|
||||||
|
if (!initializing && this._init) {
|
||||||
|
this._init.apply(this, arguments);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate our constructed prototype object
|
||||||
|
JQClass.prototype = prototype;
|
||||||
|
|
||||||
|
// Enforce the constructor to be what we expect
|
||||||
|
JQClass.prototype.constructor = JQClass;
|
||||||
|
|
||||||
|
// And make this class extendable
|
||||||
|
JQClass.extend = extender;
|
||||||
|
|
||||||
|
return JQClass;
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
/*! Abstract base class for collection plugins v1.0.2.
|
||||||
|
Written by Keith Wood (wood.keith{at}optusnet.com.au) December 2013.
|
||||||
|
Licensed under the MIT license (http://keith-wood.name/licence.html). */
|
||||||
|
(function($) { // Ensure $, encapsulate
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
/** <p>Abstract base class for collection plugins v1.0.2.</p>
|
||||||
|
<p>Written by Keith Wood (wood.keith{at}optusnet.com.au) December 2013.</p>
|
||||||
|
<p>Licensed under the MIT license (http://keith-wood.name/licence.html).</p>
|
||||||
|
<p>Use {@link $.JQPlugin.createPlugin} to create new plugins using this framework.</p>
|
||||||
|
<p>This base class provides common functionality such as:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Creates jQuery bridge - allowing you to invoke your plugin on a collection of elements.</li>
|
||||||
|
<li>Handles initialisation including reading settings from metadata -
|
||||||
|
an instance object is attached to the affected element(s) containing all the necessary data.</li>
|
||||||
|
<li>Handles option retrieval and update - options can be set through default values,
|
||||||
|
through inline metadata, or through instantiation settings.<br>
|
||||||
|
Metadata is specified as an attribute on the element:
|
||||||
|
<code>data-<pluginName>="<option name>: '<value>', ..."</code>.
|
||||||
|
Dates should be specified as strings in this format: <code>'new Date(y, m-1, d)'</code>.</li>
|
||||||
|
<li>Handles method calling - inner functions starting with '_'are inaccessible,
|
||||||
|
whereas others can be called via <code>$(selector).pluginName('functionName')</code>.</li>
|
||||||
|
<li>Handles plugin destruction - removing all trace of the plugin.</li>
|
||||||
|
</ul>
|
||||||
|
@module JQPlugin
|
||||||
|
@abstract */
|
||||||
|
JQClass.classes.JQPlugin = JQClass.extend({
|
||||||
|
|
||||||
|
/** Name to identify this plugin.
|
||||||
|
@example name: 'tabs' */
|
||||||
|
name: 'plugin',
|
||||||
|
|
||||||
|
/** Default options for instances of this plugin (default: {}).
|
||||||
|
@example defaultOptions: {
|
||||||
|
selectedClass: 'selected',
|
||||||
|
triggers: 'click'
|
||||||
|
} */
|
||||||
|
defaultOptions: {},
|
||||||
|
|
||||||
|
/** Options dependent on the locale.
|
||||||
|
Indexed by language and (optional) country code, with '' denoting the default language (English/US).
|
||||||
|
Normally additional languages would be provided as separate files to all them to be included as needed.
|
||||||
|
@example regionalOptions: {
|
||||||
|
'': {
|
||||||
|
greeting: 'Hi'
|
||||||
|
}
|
||||||
|
} */
|
||||||
|
regionalOptions: {},
|
||||||
|
|
||||||
|
/** Whether or not a deep merge should be performed when accumulating options.
|
||||||
|
The default is <code>true</code> but can be overridden in a sub-class. */
|
||||||
|
deepMerge: true,
|
||||||
|
|
||||||
|
/** Retrieve a marker class for affected elements.
|
||||||
|
In the format: <code>is-<pluginName></code>.
|
||||||
|
@protected
|
||||||
|
@return {string} The marker class. */
|
||||||
|
_getMarker: function() {
|
||||||
|
return 'is-' + this.name;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Initialise the plugin.
|
||||||
|
Create the jQuery bridge - plugin name <code>xyz</code>
|
||||||
|
produces singleton <code>$.xyz</code> and collection function <code>$.fn.xyz</code>.
|
||||||
|
@protected */
|
||||||
|
_init: function() {
|
||||||
|
// Apply default localisations
|
||||||
|
$.extend(this.defaultOptions, (this.regionalOptions && this.regionalOptions['']) || {});
|
||||||
|
// Camel-case the name
|
||||||
|
var jqName = camelCase(this.name);
|
||||||
|
// Expose jQuery singleton manager
|
||||||
|
$[jqName] = this;
|
||||||
|
// Expose jQuery collection plugin
|
||||||
|
$.fn[jqName] = function(options) {
|
||||||
|
var otherArgs = Array.prototype.slice.call(arguments, 1);
|
||||||
|
var inst = this;
|
||||||
|
var returnValue = this;
|
||||||
|
this.each(function () {
|
||||||
|
if (typeof options === 'string') {
|
||||||
|
if (options[0] === '_' || !$[jqName][options]) {
|
||||||
|
throw 'Unknown method: ' + options;
|
||||||
|
}
|
||||||
|
var methodValue = $[jqName][options].apply($[jqName], [this].concat(otherArgs));
|
||||||
|
if (methodValue !== inst && methodValue !== undefined) {
|
||||||
|
returnValue = methodValue;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$[jqName]._attach(this, options);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return returnValue;
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Set default options for all subsequent instances.
|
||||||
|
@param {object} options The new default options.
|
||||||
|
@example $.pluginName.setDefaults({name: value, ...}) */
|
||||||
|
setDefaults: function(options) {
|
||||||
|
$.extend(this.defaultOptions, options || {});
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Initialise an element. Called internally only.
|
||||||
|
Adds an instance object as data named for the plugin.
|
||||||
|
Override {@linkcode module:JQPlugin~_postAttach|_postAttach} for plugin-specific processing.
|
||||||
|
@private
|
||||||
|
@param {Element} elem The element to enhance.
|
||||||
|
@param {object} options Overriding settings. */
|
||||||
|
_attach: function(elem, options) {
|
||||||
|
elem = $(elem);
|
||||||
|
if (elem.hasClass(this._getMarker())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
elem.addClass(this._getMarker());
|
||||||
|
options = $.extend(this.deepMerge, {}, this.defaultOptions, this._getMetadata(elem), options || {});
|
||||||
|
var inst = $.extend({name: this.name, elem: elem, options: options}, this._instSettings(elem, options));
|
||||||
|
elem.data(this.name, inst); // Save instance against element
|
||||||
|
this._postAttach(elem, inst);
|
||||||
|
this.option(elem, options);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Retrieve additional instance settings.
|
||||||
|
Override this in a sub-class to provide extra settings.
|
||||||
|
These are added directly to the instance object.
|
||||||
|
Default attributes of an instance object are shown as properties below:
|
||||||
|
@protected
|
||||||
|
@param {jQuery} elem The current jQuery element.
|
||||||
|
@param {object} options The instance options.
|
||||||
|
@return {object} Any extra instance values.
|
||||||
|
@property {Element} elem The element to which this instance applies.
|
||||||
|
@property {string} name The name of this plugin.
|
||||||
|
@property {object} options The accumulated options for this instance.
|
||||||
|
@example _instSettings: function(elem, options) {
|
||||||
|
return {nav: elem.find(options.navSelector)};
|
||||||
|
} */
|
||||||
|
_instSettings: function(elem, options) { // jshint unused:false
|
||||||
|
return {};
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Plugin specific post initialisation.
|
||||||
|
Override this in a sub-class to perform extra activities.
|
||||||
|
This is where you would implement your plugin's main functionality.
|
||||||
|
@protected
|
||||||
|
@param {jQuery} elem The current jQuery element.
|
||||||
|
@param {object} inst The instance settings.
|
||||||
|
@example _postAttach: function(elem, inst) {
|
||||||
|
elem.on('click.' + this.name, function() {
|
||||||
|
...
|
||||||
|
});
|
||||||
|
} */
|
||||||
|
_postAttach: function(elem, inst) { // jshint unused:false
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Retrieve metadata configuration from the element.
|
||||||
|
Metadata is specified as an attribute:
|
||||||
|
<code>data-<pluginName>="<option name>: '<value>', ..."</code>.
|
||||||
|
Dates should be specified as strings in this format: <code>'new Date(y, m-1, d)'</code>.
|
||||||
|
@private
|
||||||
|
@param {jQuery} elem The source element.
|
||||||
|
@return {object} The inline configuration or {}. */
|
||||||
|
_getMetadata: function(elem) {
|
||||||
|
try {
|
||||||
|
var data = elem.data(this.name.toLowerCase()) || '';
|
||||||
|
data = data.replace(/(\\?)'/g, function(e, t) {
|
||||||
|
return t ? '\'' : '"';
|
||||||
|
}).replace(/([a-zA-Z0-9]+):/g, function(match, group, i) {
|
||||||
|
var count = data.substring(0, i).match(/"/g); // Handle embedded ':'
|
||||||
|
return (!count || count.length % 2 === 0 ? '"' + group + '":' : group + ':');
|
||||||
|
}).replace(/\\:/g, ':');
|
||||||
|
data = $.parseJSON('{' + data + '}');
|
||||||
|
for (var key in data) {
|
||||||
|
if (data.hasOwnProperty(key)) {
|
||||||
|
var value = data[key];
|
||||||
|
if (typeof value === 'string' && value.match(/^new Date\(([-0-9,\s]*)\)$/)) { // Convert dates
|
||||||
|
data[key] = eval(value); // jshint ignore:line
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Retrieve the instance data for element.
|
||||||
|
@protected
|
||||||
|
@param {Element} elem The source element.
|
||||||
|
@return {object} The instance data or <code>{}</code> if none. */
|
||||||
|
_getInst: function(elem) {
|
||||||
|
return $(elem).data(this.name) || {};
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Retrieve or reconfigure the settings for a plugin.
|
||||||
|
If new settings are provided they are applied to the instance options.
|
||||||
|
If an option name only is provided the value of that option is returned.
|
||||||
|
If no name or value is provided, all options are returned.
|
||||||
|
Override {@linkcode module:JQPlugin~_optionsChanged|_optionsChanged}
|
||||||
|
for plugin-specific processing when option values change.
|
||||||
|
@param {Element} elem The source element.
|
||||||
|
@param {object|string} [name] The collection of new option values or the name of a single option.
|
||||||
|
@param {any} [value] The value for a single named option.
|
||||||
|
@return {any|object} If retrieving a single value or all options.
|
||||||
|
@example $(selector).plugin('option', 'name', value) // Set one option
|
||||||
|
$(selector).plugin('option', {name: value, ...}) // Set multiple options
|
||||||
|
var value = $(selector).plugin('option', 'name') // Get one option
|
||||||
|
var options = $(selector).plugin('option') // Get all options */
|
||||||
|
option: function(elem, name, value) {
|
||||||
|
elem = $(elem);
|
||||||
|
var inst = elem.data(this.name);
|
||||||
|
var options = name || {};
|
||||||
|
if (!name || (typeof name === 'string' && typeof value === 'undefined')) {
|
||||||
|
options = (inst || {}).options;
|
||||||
|
return (options && name ? options[name] : options);
|
||||||
|
}
|
||||||
|
if (!elem.hasClass(this._getMarker())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof name === 'string') {
|
||||||
|
options = {};
|
||||||
|
options[name] = value;
|
||||||
|
}
|
||||||
|
this._optionsChanged(elem, inst, options);
|
||||||
|
$.extend(inst.options, options);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Plugin specific options processing.
|
||||||
|
Old value available in <code>inst.options[name]</code>, new value in <code>options[name]</code>.
|
||||||
|
Override this in a sub-class to perform extra activities.
|
||||||
|
@protected
|
||||||
|
@param {jQuery} elem The current jQuery element.
|
||||||
|
@param {object} inst The instance settings.
|
||||||
|
@param {object} options The new options.
|
||||||
|
@example _optionsChanged: function(elem, inst, options) {
|
||||||
|
if (options.name != inst.options.name) {
|
||||||
|
elem.removeClass(inst.options.name).addClass(options.name);
|
||||||
|
}
|
||||||
|
} */
|
||||||
|
_optionsChanged: function(elem, inst, options) { // jshint unused:false
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Remove all trace of the plugin.
|
||||||
|
Override {@linkcode module:JQPlugin~_preDestroy|_preDestroy} for plugin-specific processing.
|
||||||
|
@param {Element} elem The source element.
|
||||||
|
@example $(selector).plugin('destroy') */
|
||||||
|
destroy: function(elem) {
|
||||||
|
elem = $(elem);
|
||||||
|
if (!elem.hasClass(this._getMarker())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._preDestroy(elem, this._getInst(elem));
|
||||||
|
elem.removeData(this.name).removeClass(this._getMarker());
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Plugin specific pre destruction.
|
||||||
|
It is invoked as part of the {@linkcode module:JQPlugin~destroy|destroy} processing.
|
||||||
|
Override this in a sub-class to perform extra activities and undo everything that was
|
||||||
|
done in the {@linkcode module:JQPlugin~_postAttach|_postAttach} or
|
||||||
|
{@linkcode module:JQPlugin~_optionsChanged|_optionsChanged} functions.
|
||||||
|
@protected
|
||||||
|
@param {jQuery} elem The current jQuery element.
|
||||||
|
@param {object} inst The instance settings.
|
||||||
|
@example _preDestroy: function(elem, inst) {
|
||||||
|
elem.off('.' + this.name);
|
||||||
|
} */
|
||||||
|
_preDestroy: function(elem, inst) { // jshint unused:false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Convert names from hyphenated to camel-case.
|
||||||
|
@private
|
||||||
|
@param {string} value The original hyphenated name.
|
||||||
|
@return {string} The camel-case version. */
|
||||||
|
function camelCase(name) {
|
||||||
|
return name.replace(/-([a-z])/g, function(match, group) {
|
||||||
|
return group.toUpperCase();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Expose the plugin base.
|
||||||
|
@namespace $.JQPlugin */
|
||||||
|
$.JQPlugin = {
|
||||||
|
|
||||||
|
/** Create a new collection plugin.
|
||||||
|
@memberof $.JQPlugin
|
||||||
|
@param {string} [superClass='JQPlugin'] The name of the parent class to inherit from.
|
||||||
|
@param {object} overrides The property/function overrides for the new class.
|
||||||
|
See {@link module:JQPlugin|JQPlugin} for the base functionality.
|
||||||
|
@example $.JQPlugin.createPlugin({ // Define the plugin
|
||||||
|
name: 'tabs',
|
||||||
|
defaultOptions: {selectedClass: 'selected'},
|
||||||
|
_initSettings: function(elem, options) { return {...}; },
|
||||||
|
_postAttach: function(elem, inst) { ... }
|
||||||
|
});
|
||||||
|
$('selector').tabs(); // And instantiate it */
|
||||||
|
createPlugin: function(superClass, overrides) {
|
||||||
|
if (typeof superClass === 'object') {
|
||||||
|
overrides = superClass;
|
||||||
|
superClass = 'JQPlugin';
|
||||||
|
}
|
||||||
|
superClass = camelCase(superClass);
|
||||||
|
var className = camelCase(overrides.name);
|
||||||
|
JQClass.classes[className] = JQClass.classes[superClass].extend(overrides);
|
||||||
|
new JQClass.classes[className](); // jshint ignore:line
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
})(jQuery);
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { useCallback } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
export type LocalizedText = {
|
||||||
|
am?: string;
|
||||||
|
en?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useLocalizedName = (language?: string) => {
|
||||||
|
const { i18n } = useTranslation();
|
||||||
|
const currentLanguage = language || i18n.language;
|
||||||
|
return useCallback(
|
||||||
|
(name?: LocalizedText): string => {
|
||||||
|
if (!name) return "";
|
||||||
|
const am = name.am?.trim() ?? "";
|
||||||
|
const en = name.en?.trim() ?? "";
|
||||||
|
return (currentLanguage.startsWith("am") ? am || en : en || am) || "";
|
||||||
|
},
|
||||||
|
[currentLanguage],
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { Input } from "@/shared/common/ui/input"; // your Input component
|
||||||
|
|
||||||
|
interface FaydaInputProps {
|
||||||
|
faydaValue: string;
|
||||||
|
setFaydaValue: (value: string) => void; // make sure it's not optional
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FaydaInput: React.FC<FaydaInputProps> = ({
|
||||||
|
faydaValue,
|
||||||
|
setFaydaValue,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="block">
|
||||||
|
<span className="font-medium">Fayda ID</span>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={faydaValue}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||||
|
setFaydaValue(e.target.value)
|
||||||
|
}
|
||||||
|
placeholder="Enter Fayda ID"
|
||||||
|
className="mt-1 block w-full rounded-md border px-3 py-2"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { Progress } from "@/shared/common/ui/progress";
|
||||||
|
|
||||||
|
interface LoadingOverlayProps {
|
||||||
|
progress?: number;
|
||||||
|
text?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoadingOverlay({
|
||||||
|
progress = 50,
|
||||||
|
text = "Processing...",
|
||||||
|
}: LoadingOverlayProps) {
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-primary-300 bg-opacity-50 z-50 flex flex-col items-center justify-center space-y-4">
|
||||||
|
<div className="text-white text-lg font-semibold animate-pulse">
|
||||||
|
{text}
|
||||||
|
</div>
|
||||||
|
<Progress value={progress} className="w-3/4 md:w-1/3 h-3 rounded-full" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { FC } from "react";
|
||||||
|
// @ts-ignore - Module types not available
|
||||||
|
import * as SliderPrimitive from "@radix-ui/react-slider";
|
||||||
|
|
||||||
|
interface SliderUIProps {
|
||||||
|
id?: string;
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
value: number[];
|
||||||
|
onChange: (value: number) => void;
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
step?: number;
|
||||||
|
required?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SliderUI: FC<SliderUIProps> = ({
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
min = 0,
|
||||||
|
max = 100,
|
||||||
|
step = 1,
|
||||||
|
required = false,
|
||||||
|
disabled = false,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor={id} className="block text-sm font-medium">
|
||||||
|
{label} {required && <span className="text-red-500">*</span>}
|
||||||
|
</label>
|
||||||
|
{description && <p className="text-xs text-gray-500">{description}</p>}
|
||||||
|
<SliderPrimitive.Root
|
||||||
|
id={id}
|
||||||
|
value={value}
|
||||||
|
onValueChange={(val: number[]) => onChange(val[0])}
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
step={step}
|
||||||
|
disabled={disabled}
|
||||||
|
className="relative flex w-full touch-none select-none items-center h-5">
|
||||||
|
<SliderPrimitive.Track className="relative bg-gray-200 flex-1 h-1 rounded-full">
|
||||||
|
<SliderPrimitive.Range className="absolute bg-blue-500 h-full rounded-full" />
|
||||||
|
</SliderPrimitive.Track>
|
||||||
|
<SliderPrimitive.Thumb className="block w-5 h-5 bg-white border border-gray-300 rounded-full shadow" />
|
||||||
|
</SliderPrimitive.Root>
|
||||||
|
<div className="text-sm text-gray-600 text-center">{value}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
// src/shared/common/ui/text.tsx
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
type TextProps = React.HTMLAttributes<HTMLSpanElement> & {
|
||||||
|
fw?: number | string;
|
||||||
|
color?: string;
|
||||||
|
size?: string | number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Text: React.FC<TextProps> = ({
|
||||||
|
fw,
|
||||||
|
color,
|
||||||
|
size,
|
||||||
|
style,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
style={{ fontWeight: fw, color, fontSize: size, ...style }}
|
||||||
|
{...props}>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import * as AccordionPrimitive from "@radix-ui/react-accordion";
|
||||||
|
import { ChevronDownIcon } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
|
||||||
|
function Accordion({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
|
||||||
|
return <AccordionPrimitive.Root data-slot="accordion" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccordionItem({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<AccordionPrimitive.Item
|
||||||
|
data-slot="accordion-item"
|
||||||
|
className={cn("border-b last:border-b-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccordionTrigger({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<AccordionPrimitive.Header className="flex">
|
||||||
|
<AccordionPrimitive.Trigger
|
||||||
|
data-slot="accordion-trigger"
|
||||||
|
className={cn(
|
||||||
|
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
|
||||||
|
</AccordionPrimitive.Trigger>
|
||||||
|
</AccordionPrimitive.Header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccordionContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<AccordionPrimitive.Content
|
||||||
|
data-slot="accordion-content"
|
||||||
|
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className={cn("pt-0 pb-4", className)}>{children}</div>
|
||||||
|
</AccordionPrimitive.Content>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
|
||||||
|
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
import { buttonVariants } from "@/shared/common/ui/button";
|
||||||
|
|
||||||
|
function AlertDialog({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
||||||
|
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Overlay
|
||||||
|
data-slot="alert-dialog-overlay"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPortal>
|
||||||
|
<AlertDialogOverlay />
|
||||||
|
<AlertDialogPrimitive.Content
|
||||||
|
data-slot="alert-dialog-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</AlertDialogPortal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogHeader({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-header"
|
||||||
|
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogFooter({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-footer"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Title
|
||||||
|
data-slot="alert-dialog-title"
|
||||||
|
className={cn("text-lg font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Description
|
||||||
|
data-slot="alert-dialog-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogAction({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Action
|
||||||
|
className={cn(buttonVariants(), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogCancel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Cancel
|
||||||
|
className={cn(buttonVariants({ variant: "outline" }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogPortal,
|
||||||
|
AlertDialogOverlay,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
};
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
import { cn } from "@/super-admin/lib/utils";
|
||||||
|
|
||||||
|
const alertVariants = cva(
|
||||||
|
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-background text-foreground",
|
||||||
|
destructive:
|
||||||
|
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const Alert = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||||
|
>(({ className, variant, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
role="alert"
|
||||||
|
className={cn(alertVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
Alert.displayName = "Alert";
|
||||||
|
|
||||||
|
const AlertTitle = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLHeadingElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<h5
|
||||||
|
ref={ref}
|
||||||
|
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
AlertTitle.displayName = "AlertTitle";
|
||||||
|
|
||||||
|
const AlertDescription = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm [&_p]:leading-relaxed", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
AlertDescription.displayName = "AlertDescription";
|
||||||
|
|
||||||
|
export { Alert, AlertTitle, AlertDescription };
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as AvatarPrimitive from "@radix-ui/react-avatar";
|
||||||
|
|
||||||
|
import { cn } from "@/super-admin/lib/utils";
|
||||||
|
|
||||||
|
function Avatar({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Root
|
||||||
|
data-slot="avatar"
|
||||||
|
className={cn(
|
||||||
|
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarImage({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Image
|
||||||
|
data-slot="avatar-image"
|
||||||
|
className={cn("aspect-square size-full", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarFallback({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Fallback
|
||||||
|
data-slot="avatar-fallback"
|
||||||
|
className={cn(
|
||||||
|
"bg-muted flex size-full items-center justify-center rounded-full",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Avatar, AvatarImage, AvatarFallback };
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
//the status edit
|
||||||
|
const badgeVariants = cva(
|
||||||
|
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
//draft and forward
|
||||||
|
default:
|
||||||
|
"bg-blue-100 text-blue-800 text-sm font-medium me-2 px-3 py-0.5 rounded-full dark:bg-blue-900 dark:text-blue-300",
|
||||||
|
secondary:
|
||||||
|
"bg-indigo-100 text-indigo-800 text-sm font-medium me-2 px-2.5 py-0.5 rounded-full dark:bg-indigo-900 dark:text-indigo-300",
|
||||||
|
destructive:
|
||||||
|
"bg-gray-100 text-gray-800 text-sm font-medium me-2 px-2.5 py-0.5 rounded-sm dark:bg-gray-700 dark:text-gray-300",
|
||||||
|
outline:
|
||||||
|
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||||
|
info: "border-transparent bg-info text-info-foreground [a&]:hover:bg-info/90",
|
||||||
|
purple:
|
||||||
|
"bg-purple-100 text-purple-800 text-sm font-medium me-2 px-2.5 py-0.5 rounded-full dark:bg-purple-900 dark:text-purple-300",
|
||||||
|
warning:
|
||||||
|
"bg-pink-100 text-pink-800 text-sm font-medium me-2 px-2.5 py-0.5 rounded-full dark:bg-pink-900 dark:text-pink-300",
|
||||||
|
//approved
|
||||||
|
success:
|
||||||
|
"bg-green-100 text-green-800 text-sm font-medium me-2 px-2.5 py-0.5 rounded-full dark:bg-green-900 dark:text-green-300",
|
||||||
|
danger:
|
||||||
|
"bg-red-100 text-red-800 text-sm font-medium me-2 px-2.5 py-0.5 rounded-full dark:bg-red-900 dark:text-red-300",
|
||||||
|
danger2:
|
||||||
|
"bg-yellow-100 text-yellow-800 text-sm font-medium me-2 px-2.5 py-0.5 rounded-full dark:bg-yellow-900 dark:text-yellow-300",
|
||||||
|
forward:
|
||||||
|
"bg-orange-100 text-orange-800 text-sm font-medium me-2 px-3 py-0.5 rounded-full dark:bg-orange-900 dark:text-orange-300",
|
||||||
|
dispatched:
|
||||||
|
"bg-green-100 text-green-800 text-sm font-medium me-2 px-2.5 py-0.5 rounded-full dark:bg-green-900 dark:text-green-300",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
function Badge({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span"> &
|
||||||
|
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||||
|
const Comp = asChild ? Slot : "span";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="badge"
|
||||||
|
className={cn(badgeVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants };
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
|
import { ChevronRight, MoreHorizontal } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
|
||||||
|
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
|
||||||
|
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
|
||||||
|
return (
|
||||||
|
<ol
|
||||||
|
data-slot="breadcrumb-list"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
data-slot="breadcrumb-item"
|
||||||
|
className={cn("inline-flex items-center gap-1.5", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbLink({
|
||||||
|
asChild,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"a"> & {
|
||||||
|
asChild?: boolean;
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot : "a";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="breadcrumb-link"
|
||||||
|
className={cn("hover:text-foreground transition-colors", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="breadcrumb-page"
|
||||||
|
role="link"
|
||||||
|
aria-disabled="true"
|
||||||
|
aria-current="page"
|
||||||
|
className={cn("text-foreground font-normal", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbSeparator({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"li">) {
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
data-slot="breadcrumb-separator"
|
||||||
|
role="presentation"
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn("[&>svg]:size-3.5", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children ?? <ChevronRight />}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbEllipsis({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="breadcrumb-ellipsis"
|
||||||
|
role="presentation"
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn("flex size-9 items-center justify-center", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="size-4" />
|
||||||
|
<span className="sr-only">More</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Breadcrumb,
|
||||||
|
BreadcrumbList,
|
||||||
|
BreadcrumbItem,
|
||||||
|
BreadcrumbLink,
|
||||||
|
BreadcrumbPage,
|
||||||
|
BreadcrumbSeparator,
|
||||||
|
BreadcrumbEllipsis,
|
||||||
|
};
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-[color,box-shadow] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40",
|
||||||
|
outline:
|
||||||
|
"border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||||
|
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||||
|
link: "text-muted-foreground underline-offset-13 hover:underline hover:text-primary",
|
||||||
|
linkActive: "text-primary-700 underline-offset-13 underline -mt-2",
|
||||||
|
addRecordBtn:
|
||||||
|
"bg-primary text-white size-35 shadow-xs hover:bg-primary-700 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40",
|
||||||
|
viewDetail:
|
||||||
|
"bg-transparent border border-primary text-primary text-base font-normal me-2 !px-2.5 py-0.5 rounded-full scale-90 origin-center hover:bg-primary/10 transition-colors dark:bg-primary/20 dark:border-primary dark:text-primary-foreground leading-tight whitespace-nowrap",
|
||||||
|
incomingmenu:
|
||||||
|
"bg-transparent border border-primary text-primary text-md font-normal me-2 !px-4 py-0.5 rounded-md w-[170px] scale-90 origin-center hover:bg-primary/10 transition-colors dark:bg-primary/20 dark:border-primary dark:text-primary-foreground leading-tight whitespace-nowrap",
|
||||||
|
incomingmenu1:
|
||||||
|
"bg-transparent border border-gray-500 text-gray-500 text-md font-normal me-2 !px-4 py-0.5 rounded-md w-[170px] scale-90 origin-center hover:bg-gray-500/10 transition-colors dark:border-gray-300 dark:text-gray-300 leading-tight whitespace-nowrap",
|
||||||
|
actionGreen:
|
||||||
|
"bg-emerald-50/90 dark:bg-emerald-950/20 border border-emerald-200 dark:border-emerald-900/40 text-emerald-700 dark:text-emerald-300 hover:bg-emerald-100 dark:hover:bg-emerald-950/40 hover:text-emerald-800 dark:hover:text-emerald-200 hover:border-emerald-300 dark:hover:border-emerald-800/60 focus-visible:ring-emerald-500 rounded-full h-9 px-4 flex items-center gap-2 font-medium shadow-xs hover:shadow-md transition-all duration-300",
|
||||||
|
actionLightGreen:
|
||||||
|
"bg-green-50/50 dark:bg-green-950/10 border border-green-100 dark:border-green-900/20 text-green-600 dark:text-green-400 hover:bg-green-100/50 dark:hover:bg-green-950/30 hover:text-green-700 dark:hover:text-green-300 hover:border-green-200 dark:hover:border-green-800/40 focus-visible:ring-green-500 rounded-full h-9 px-4 flex items-center gap-2 font-medium shadow-xs hover:shadow-md transition-all duration-300",
|
||||||
|
actionRed:
|
||||||
|
"bg-rose-50/90 dark:bg-rose-950/20 border border-rose-200 dark:border-rose-900/40 text-rose-700 dark:text-rose-300 hover:bg-rose-100 dark:hover:bg-rose-950/40 hover:text-rose-800 dark:hover:text-rose-200 hover:border-rose-300 dark:hover:border-rose-800/60 focus-visible:ring-rose-500 rounded-full h-9 px-4 flex items-center gap-2 font-medium shadow-xs hover:shadow-md transition-all duration-300",
|
||||||
|
actionYellow:
|
||||||
|
"bg-amber-50/90 dark:bg-amber-950/20 border border-amber-200 dark:border-amber-900/40 text-amber-700 dark:text-amber-300 hover:bg-amber-100 dark:hover:bg-amber-950/40 hover:text-amber-800 dark:hover:text-amber-200 hover:border-amber-300 dark:hover:border-amber-800/60 focus-visible:ring-amber-500 rounded-full h-9 px-4 flex items-center gap-2 font-medium shadow-xs hover:shadow-md transition-all duration-300",
|
||||||
|
actionBlue:
|
||||||
|
"bg-blue-50/90 dark:bg-blue-950/20 border border-blue-200 dark:border-blue-900/40 text-blue-700 dark:text-blue-300 hover:bg-blue-100 dark:hover:bg-blue-950/40 hover:text-blue-800 dark:hover:text-blue-200 hover:border-blue-300 dark:hover:border-blue-800/60 focus-visible:ring-blue-500 rounded-full h-9 px-4 flex items-center gap-2 font-medium shadow-xs hover:shadow-md transition-all duration-300",
|
||||||
|
actionPurple:
|
||||||
|
"bg-purple-50/90 dark:bg-purple-950/20 border border-purple-200 dark:border-purple-900/40 text-purple-700 dark:text-purple-300 hover:bg-purple-100 dark:hover:bg-purple-950/40 hover:text-purple-800 dark:hover:text-purple-200 hover:border-purple-300 dark:hover:border-purple-800/60 focus-visible:ring-purple-500 rounded-full h-9 px-4 flex items-center gap-2 font-medium shadow-xs hover:shadow-md transition-all duration-300",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||||
|
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||||
|
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||||
|
icon: "size-9",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
type ButtonProps = React.ComponentProps<"button"> &
|
||||||
|
VariantProps<typeof buttonVariants> & {
|
||||||
|
asChild?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, asChild = false, type, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : "button";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="button"
|
||||||
|
// When `asChild` is used, Radix will attach the ref to the child via Slot.
|
||||||
|
ref={ref as any}
|
||||||
|
className={cn(
|
||||||
|
buttonVariants({ variant, size, className }),
|
||||||
|
"cursor-pointer",
|
||||||
|
)}
|
||||||
|
{...(asChild ? {} : { type: type ?? "button" })}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
Button.displayName = "Button";
|
||||||
|
|
||||||
|
export function getActionButtonVariant(actionName: string): any {
|
||||||
|
const normalized = actionName.trim().toLowerCase();
|
||||||
|
|
||||||
|
if (
|
||||||
|
normalized.includes("approve") ||
|
||||||
|
normalized.includes("sign") ||
|
||||||
|
normalized.includes("reply") ||
|
||||||
|
normalized === "accept"
|
||||||
|
) {
|
||||||
|
return "actionGreen";
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
normalized.includes("accept and forward") ||
|
||||||
|
normalized.includes("accept & forward")
|
||||||
|
) {
|
||||||
|
return "actionLightGreen";
|
||||||
|
}
|
||||||
|
if (normalized.includes("delete") || normalized.includes("reject")) {
|
||||||
|
return "actionRed";
|
||||||
|
}
|
||||||
|
if (normalized.includes("forward")) {
|
||||||
|
return "actionPurple";
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
normalized.includes("adjustment") ||
|
||||||
|
normalized.includes("adjust") ||
|
||||||
|
normalized.includes("secure letter") ||
|
||||||
|
normalized.includes("make urgent") ||
|
||||||
|
normalized.includes("urgent") ||
|
||||||
|
normalized.includes("assign") ||
|
||||||
|
normalized.includes("reassign") ||
|
||||||
|
normalized.includes("under review") ||
|
||||||
|
normalized.includes("return")
|
||||||
|
) {
|
||||||
|
return "actionYellow";
|
||||||
|
}
|
||||||
|
if (normalized.includes("edit") || normalized.includes("comment")) {
|
||||||
|
return "actionBlue";
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
normalized.includes("send") ||
|
||||||
|
normalized.includes("dispatch") ||
|
||||||
|
normalized.includes("share")
|
||||||
|
) {
|
||||||
|
return "actionPurple";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom keyword heuristics
|
||||||
|
if (
|
||||||
|
normalized.includes("remove") ||
|
||||||
|
normalized.includes("cancel") ||
|
||||||
|
normalized.includes("fail") ||
|
||||||
|
normalized.includes("discard")
|
||||||
|
) {
|
||||||
|
return "actionRed";
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
normalized.includes("warn") ||
|
||||||
|
normalized.includes("caution") ||
|
||||||
|
normalized.includes("alert") ||
|
||||||
|
normalized.includes("pending")
|
||||||
|
) {
|
||||||
|
return "actionYellow";
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
normalized.includes("success") ||
|
||||||
|
normalized.includes("complete") ||
|
||||||
|
normalized.includes("done") ||
|
||||||
|
normalized.includes("save")
|
||||||
|
) {
|
||||||
|
return "actionGreen";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "actionBlue"; // Informational fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Button, buttonVariants };
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
|
import { DayPicker } from "react-day-picker";
|
||||||
|
import "react-day-picker/style.css";
|
||||||
|
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
import { buttonVariants } from "@/shared/common/ui/button";
|
||||||
|
|
||||||
|
function Calendar({
|
||||||
|
className,
|
||||||
|
classNames,
|
||||||
|
showOutsideDays = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DayPicker>) {
|
||||||
|
return (
|
||||||
|
<DayPicker
|
||||||
|
showOutsideDays={showOutsideDays}
|
||||||
|
{...props}
|
||||||
|
className={cn(
|
||||||
|
"rdp w-full rounded-xl border border-slate-200 bg-white p-3 shadow-[0_10px_30px_-20px_rgba(15,23,42,0.5)] dark:border-slate-700 dark:bg-slate-900",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
classNames={{
|
||||||
|
months: "rdp-months flex flex-col gap-4 sm:flex-row sm:gap-6",
|
||||||
|
month: "rdp-month space-y-4",
|
||||||
|
caption: "rdp-caption relative flex items-center justify-center pt-1",
|
||||||
|
caption_label:
|
||||||
|
"rdp-caption_label text-sm font-semibold tracking-wide text-slate-800 dark:text-slate-100",
|
||||||
|
nav: "rdp-nav flex items-center gap-1",
|
||||||
|
nav_button: cn(
|
||||||
|
"rdp-nav_button",
|
||||||
|
buttonVariants({ variant: "ghost", size: "icon" }),
|
||||||
|
"h-8 w-8 rounded-lg text-slate-600 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-300 dark:hover:bg-slate-800 dark:hover:text-slate-100",
|
||||||
|
),
|
||||||
|
nav_button_previous: "rdp-nav_button_previous absolute left-0",
|
||||||
|
nav_button_next: "rdp-nav_button_next absolute right-0",
|
||||||
|
table: "rdp-table w-full border-collapse space-y-1",
|
||||||
|
head_row: "rdp-head_row flex w-full justify-between",
|
||||||
|
head_cell:
|
||||||
|
"rdp-head_cell text-slate-500 rounded-md w-9 font-normal text-[0.8rem] dark:text-slate-400 uppercase tracking-wide text-center pt-1 mb-2 whitespace-nowrap",
|
||||||
|
row: "rdp-row flex w-full justify-between mt-1",
|
||||||
|
cell: cn(
|
||||||
|
"rdp-cell relative h-9 w-9 p-0 text-center text-sm focus-within:relative focus-within:z-10",
|
||||||
|
props.mode === "range"
|
||||||
|
? "[&:has([aria-selected].day-range-end)]:rounded-r-lg [&:has([aria-selected].day-range-start)]:rounded-l-lg [&:has([aria-selected].day-range-middle)]:bg-primary-50 dark:[&:has([aria-selected].day-range-middle)]:bg-primary-900/30"
|
||||||
|
: "",
|
||||||
|
),
|
||||||
|
day: cn(
|
||||||
|
"rdp-day",
|
||||||
|
buttonVariants({ variant: "ghost" }),
|
||||||
|
"h-9 w-9 rounded-lg p-0 font-medium text-slate-700 hover:bg-slate-100 hover:text-slate-900 focus-visible:ring-2 focus-visible:ring-primary-500/30 aria-selected:opacity-100 dark:text-slate-200 dark:hover:bg-slate-800 dark:hover:text-white",
|
||||||
|
),
|
||||||
|
day_selected:
|
||||||
|
"rdp-day_selected bg-primary-600 text-white hover:bg-primary-600 hover:text-white focus:bg-primary-600 focus:text-white dark:bg-primary-500 dark:hover:bg-primary-500",
|
||||||
|
day_today:
|
||||||
|
"rdp-day_today border border-primary-300 bg-primary-50 text-primary-700 dark:border-primary-500/50 dark:bg-primary-900/30 dark:text-primary-300",
|
||||||
|
day_outside:
|
||||||
|
"rdp-day_outside text-slate-400 opacity-60 aria-selected:bg-primary-100 aria-selected:text-primary-700 dark:text-slate-600 dark:aria-selected:bg-primary-900/40 dark:aria-selected:text-primary-300",
|
||||||
|
day_disabled: "rdp-day_disabled text-slate-300 opacity-50 dark:text-slate-600",
|
||||||
|
day_range_middle:
|
||||||
|
"rdp-day_range_middle aria-selected:bg-primary-100 aria-selected:text-primary-800 dark:aria-selected:bg-primary-900/40 dark:aria-selected:text-primary-200",
|
||||||
|
day_range_start:
|
||||||
|
"rdp-day_range_start day-range-start aria-selected:bg-primary-600 aria-selected:text-white",
|
||||||
|
day_range_end:
|
||||||
|
"rdp-day_range_end day-range-end aria-selected:bg-primary-600 aria-selected:text-white",
|
||||||
|
day_hidden: "rdp-day_hidden invisible",
|
||||||
|
...classNames,
|
||||||
|
}}
|
||||||
|
// Removed unsupported 'Chevron' component override
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Calendar };
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
|
||||||
|
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card"
|
||||||
|
className={cn(
|
||||||
|
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-header"
|
||||||
|
className={cn("flex flex-col gap-1.5 px-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-title"
|
||||||
|
className={cn("leading-none font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-content"
|
||||||
|
className={cn("px-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-footer"
|
||||||
|
className={cn("flex items-center px-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardFooter,
|
||||||
|
CardTitle,
|
||||||
|
CardDescription,
|
||||||
|
CardContent,
|
||||||
|
};
|
||||||
@@ -0,0 +1,355 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import * as RechartsPrimitive from "recharts";
|
||||||
|
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
|
||||||
|
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||||
|
const THEMES = { light: "", dark: ".dark" } as const;
|
||||||
|
|
||||||
|
export type ChartConfig = {
|
||||||
|
[k in string]: {
|
||||||
|
label?: React.ReactNode;
|
||||||
|
icon?: React.ComponentType;
|
||||||
|
} & (
|
||||||
|
| { color?: string; theme?: never }
|
||||||
|
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type ChartContextProps = {
|
||||||
|
config: ChartConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ChartContext = React.createContext<ChartContextProps | null>(null);
|
||||||
|
|
||||||
|
function useChart() {
|
||||||
|
const context = React.useContext(ChartContext);
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useChart must be used within a <ChartContainer />");
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChartContainer({
|
||||||
|
id,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
config,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
config: ChartConfig;
|
||||||
|
children: React.ComponentProps<
|
||||||
|
typeof RechartsPrimitive.ResponsiveContainer
|
||||||
|
>["children"];
|
||||||
|
}) {
|
||||||
|
const uniqueId = React.useId();
|
||||||
|
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChartContext.Provider value={{ config }}>
|
||||||
|
<div
|
||||||
|
data-slot="chart"
|
||||||
|
data-chart={chartId}
|
||||||
|
className={cn(
|
||||||
|
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChartStyle id={chartId} config={config} />
|
||||||
|
<RechartsPrimitive.ResponsiveContainer>
|
||||||
|
{children}
|
||||||
|
</RechartsPrimitive.ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</ChartContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||||
|
const colorConfig = Object.entries(config).filter(
|
||||||
|
([, config]) => config.theme || config.color
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!colorConfig.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<style
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: Object.entries(THEMES)
|
||||||
|
.map(
|
||||||
|
([theme, prefix]) => `
|
||||||
|
${prefix} [data-chart=${id}] {
|
||||||
|
${colorConfig
|
||||||
|
.map(([key, itemConfig]) => {
|
||||||
|
const color =
|
||||||
|
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
|
||||||
|
itemConfig.color;
|
||||||
|
return color ? ` --color-${key}: ${color};` : null;
|
||||||
|
})
|
||||||
|
.join("\n")}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
)
|
||||||
|
.join("\n"),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ChartTooltip = RechartsPrimitive.Tooltip;
|
||||||
|
|
||||||
|
function ChartTooltipContent({
|
||||||
|
active,
|
||||||
|
payload,
|
||||||
|
className,
|
||||||
|
indicator = "dot",
|
||||||
|
hideLabel = false,
|
||||||
|
hideIndicator = false,
|
||||||
|
label,
|
||||||
|
labelFormatter,
|
||||||
|
labelClassName,
|
||||||
|
formatter,
|
||||||
|
color,
|
||||||
|
nameKey,
|
||||||
|
labelKey,
|
||||||
|
}: Omit<React.ComponentProps<typeof RechartsPrimitive.Tooltip>, "payload"> &
|
||||||
|
React.ComponentProps<"div"> & {
|
||||||
|
payload?: Array<any>;
|
||||||
|
hideLabel?: boolean;
|
||||||
|
hideIndicator?: boolean;
|
||||||
|
indicator?: "line" | "dot" | "dashed";
|
||||||
|
nameKey?: string;
|
||||||
|
labelKey?: string;
|
||||||
|
label?: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const { config } = useChart();
|
||||||
|
|
||||||
|
const tooltipLabel = React.useMemo(() => {
|
||||||
|
if (hideLabel || !payload?.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [item] = payload;
|
||||||
|
const key = `${labelKey || item?.dataKey || item?.name || "value"}`;
|
||||||
|
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||||
|
const value =
|
||||||
|
!labelKey && typeof label === "string"
|
||||||
|
? config[label as keyof typeof config]?.label || label
|
||||||
|
: itemConfig?.label;
|
||||||
|
|
||||||
|
if (labelFormatter) {
|
||||||
|
return (
|
||||||
|
<div className={cn("font-medium", labelClassName)}>
|
||||||
|
{labelFormatter(value, payload as any)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
|
||||||
|
}, [
|
||||||
|
label,
|
||||||
|
labelFormatter,
|
||||||
|
payload,
|
||||||
|
hideLabel,
|
||||||
|
labelClassName,
|
||||||
|
config,
|
||||||
|
labelKey,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!active || !payload?.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nestLabel = payload.length === 1 && indicator !== "dot";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{!nestLabel ? tooltipLabel : null}
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
{payload.map((item, index) => {
|
||||||
|
const key = `${nameKey || item.name || item.dataKey || "value"}`;
|
||||||
|
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||||
|
const indicatorColor = color || item.payload.fill || item.color;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.dataKey}
|
||||||
|
className={cn(
|
||||||
|
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
|
||||||
|
indicator === "dot" && "items-center"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formatter && item?.value !== undefined && item.name ? (
|
||||||
|
formatter(item.value, item.name, item as any, index, item.payload as any)
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{itemConfig?.icon ? (
|
||||||
|
<itemConfig.icon />
|
||||||
|
) : (
|
||||||
|
!hideIndicator && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
|
||||||
|
{
|
||||||
|
"h-2.5 w-2.5": indicator === "dot",
|
||||||
|
"w-1": indicator === "line",
|
||||||
|
"w-0 border-[1.5px] border-dashed bg-transparent":
|
||||||
|
indicator === "dashed",
|
||||||
|
"my-0.5": nestLabel && indicator === "dashed",
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--color-bg": indicatorColor,
|
||||||
|
"--color-border": indicatorColor,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-1 justify-between leading-none",
|
||||||
|
nestLabel ? "items-end" : "items-center"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
{nestLabel ? tooltipLabel : null}
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{itemConfig?.label || (typeof item.name === "string" ? item.name : (item.name as any)?.en || (item.name as any)?.am || "Item")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{item.value && (
|
||||||
|
<span className="text-foreground font-mono font-medium tabular-nums">
|
||||||
|
{item.value.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChartLegend = RechartsPrimitive.Legend;
|
||||||
|
|
||||||
|
function ChartLegendContent({
|
||||||
|
className,
|
||||||
|
hideIcon = false,
|
||||||
|
payload,
|
||||||
|
verticalAlign = "bottom",
|
||||||
|
nameKey,
|
||||||
|
}: React.ComponentProps<"div"> &
|
||||||
|
{
|
||||||
|
payload?: { dataKey?: string; value?: string; color?: string }[];
|
||||||
|
verticalAlign?: "top" | "bottom" | "middle";
|
||||||
|
hideIcon?: boolean;
|
||||||
|
nameKey?: string;
|
||||||
|
}) {
|
||||||
|
const { config } = useChart();
|
||||||
|
|
||||||
|
if (!payload?.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-center gap-4",
|
||||||
|
verticalAlign === "top" ? "pb-3" : "pt-3",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{payload.map((item) => {
|
||||||
|
const key = `${nameKey || item.dataKey || "value"}`;
|
||||||
|
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.value}
|
||||||
|
className={cn(
|
||||||
|
"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{itemConfig?.icon && !hideIcon ? (
|
||||||
|
<itemConfig.icon />
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"h-2 w-2 shrink-0 rounded-[2px]",
|
||||||
|
item.color ? `chart-legend-color--${item.color.replace("#", "")}` : ""
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{itemConfig?.label}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to extract item config from a payload.
|
||||||
|
function getPayloadConfigFromPayload(
|
||||||
|
config: ChartConfig,
|
||||||
|
payload: unknown,
|
||||||
|
key: string
|
||||||
|
) {
|
||||||
|
if (typeof payload !== "object" || payload === null) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payloadPayload =
|
||||||
|
"payload" in payload &&
|
||||||
|
typeof payload.payload === "object" &&
|
||||||
|
payload.payload !== null
|
||||||
|
? payload.payload
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
let configLabelKey: string = key;
|
||||||
|
|
||||||
|
if (
|
||||||
|
key in payload &&
|
||||||
|
typeof payload[key as keyof typeof payload] === "string"
|
||||||
|
) {
|
||||||
|
configLabelKey = payload[key as keyof typeof payload] as string;
|
||||||
|
} else if (
|
||||||
|
payloadPayload &&
|
||||||
|
key in payloadPayload &&
|
||||||
|
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
|
||||||
|
) {
|
||||||
|
configLabelKey = payloadPayload[
|
||||||
|
key as keyof typeof payloadPayload
|
||||||
|
] as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
return configLabelKey in config
|
||||||
|
? config[configLabelKey]
|
||||||
|
: config[key as keyof typeof config];
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
ChartContainer,
|
||||||
|
ChartTooltip,
|
||||||
|
ChartTooltipContent,
|
||||||
|
ChartLegend,
|
||||||
|
ChartLegendContent,
|
||||||
|
ChartStyle,
|
||||||
|
};
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
|
||||||
|
import { CheckIcon } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
|
||||||
|
// Define variants using CVA
|
||||||
|
const checkboxVariants = cva(
|
||||||
|
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "",
|
||||||
|
//soft: "rounded-full bg-primary/10 border-primary text-primary data-[state=checked]:bg-primary data-[state=checked]:text-white data-[state=checked]:border-primary",
|
||||||
|
subtle: "border-muted bg-muted/20",
|
||||||
|
soft: `
|
||||||
|
size-5 rounded-full border-2 border-primary bg-transparent
|
||||||
|
data-[state=checked]:bg-primary
|
||||||
|
data-[state=checked]:border-primary
|
||||||
|
transition-colors
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
sm: "size-3",
|
||||||
|
md: "size-4",
|
||||||
|
lg: "size-5",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "md",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
function Checkbox({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
size = "md",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CheckboxPrimitive.Root> &
|
||||||
|
VariantProps<typeof checkboxVariants>) {
|
||||||
|
return (
|
||||||
|
<CheckboxPrimitive.Root
|
||||||
|
data-slot="checkbox"
|
||||||
|
className={cn(checkboxVariants({ variant, size }), className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<CheckboxPrimitive.Indicator
|
||||||
|
data-slot="checkbox-indicator"
|
||||||
|
className="flex items-center justify-center text-current transition-none"
|
||||||
|
>
|
||||||
|
<CheckIcon className="size-3.5" />
|
||||||
|
</CheckboxPrimitive.Indicator>
|
||||||
|
</CheckboxPrimitive.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Checkbox, checkboxVariants };
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
|
||||||
|
|
||||||
|
function Collapsible({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
|
||||||
|
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function CollapsibleTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
|
||||||
|
return (
|
||||||
|
<CollapsiblePrimitive.CollapsibleTrigger
|
||||||
|
data-slot="collapsible-trigger"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CollapsibleContent({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
|
||||||
|
return (
|
||||||
|
<CollapsiblePrimitive.CollapsibleContent
|
||||||
|
data-slot="collapsible-content"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { Command as CommandPrimitive } from "cmdk";
|
||||||
|
import { SearchIcon } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/shared/common/ui/dialog";
|
||||||
|
|
||||||
|
function Command({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive
|
||||||
|
data-slot="command"
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandDialog({
|
||||||
|
title = "Command Palette",
|
||||||
|
description = "Search for a command to run...",
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Dialog> & {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Dialog {...props}>
|
||||||
|
<DialogHeader className="sr-only">
|
||||||
|
<DialogTitle>{title}</DialogTitle>
|
||||||
|
<DialogDescription>{description}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogContent className="overflow-hidden p-0">
|
||||||
|
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||||
|
{children}
|
||||||
|
</Command>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandInput({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="command-input-wrapper"
|
||||||
|
className="flex h-9 items-center gap-2 border-b px-3 dark:border-gray-600"
|
||||||
|
>
|
||||||
|
<SearchIcon className="size-4 shrink-0 opacity-50 dark:text-gray-400" />
|
||||||
|
<CommandPrimitive.Input
|
||||||
|
data-slot="command-input"
|
||||||
|
className={cn(
|
||||||
|
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandList({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.List>) {
|
||||||
|
const listRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const listElement = listRef.current;
|
||||||
|
if (!listElement) return;
|
||||||
|
|
||||||
|
const handleWheel = (e: WheelEvent) => {
|
||||||
|
// Allow scrolling within the list
|
||||||
|
const { scrollTop, scrollHeight, clientHeight } = listElement;
|
||||||
|
const isScrollingUp = e.deltaY < 0;
|
||||||
|
const isScrollingDown = e.deltaY > 0;
|
||||||
|
const isAtTop = scrollTop === 0;
|
||||||
|
const isAtBottom = scrollTop + clientHeight >= scrollHeight;
|
||||||
|
|
||||||
|
// Only prevent default if we're not at the boundaries
|
||||||
|
if ((isScrollingUp && !isAtTop) || (isScrollingDown && !isAtBottom)) {
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
listElement.addEventListener("wheel", handleWheel, { passive: false });
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
listElement.removeEventListener("wheel", handleWheel);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.List
|
||||||
|
ref={listRef}
|
||||||
|
data-slot="command-list"
|
||||||
|
className={cn(
|
||||||
|
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto overscroll-contain custom-scrollbar",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
scrollbarWidth: "thin",
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandEmpty({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.Empty
|
||||||
|
data-slot="command-empty"
|
||||||
|
className="py-6 text-center text-sm"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandGroup({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.Group
|
||||||
|
data-slot="command-group"
|
||||||
|
className={cn(
|
||||||
|
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.Separator
|
||||||
|
data-slot="command-separator"
|
||||||
|
className={cn("bg-border -mx-1 h-px", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandItem({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.Item
|
||||||
|
data-slot="command-item"
|
||||||
|
className={cn(
|
||||||
|
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandShortcut({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="command-shortcut"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Command,
|
||||||
|
CommandDialog,
|
||||||
|
CommandInput,
|
||||||
|
CommandList,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandItem,
|
||||||
|
CommandShortcut,
|
||||||
|
CommandSeparator,
|
||||||
|
};
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu";
|
||||||
|
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
|
||||||
|
function ContextMenu({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
|
||||||
|
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuSub({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
|
||||||
|
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuRadioGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.RadioGroup
|
||||||
|
data-slot="context-menu-radio-group"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuSubTrigger({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
|
||||||
|
inset?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.SubTrigger
|
||||||
|
data-slot="context-menu-sub-trigger"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRightIcon className="ml-auto" />
|
||||||
|
</ContextMenuPrimitive.SubTrigger>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuSubContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.SubContent
|
||||||
|
data-slot="context-menu-sub-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.Portal>
|
||||||
|
<ContextMenuPrimitive.Content
|
||||||
|
data-slot="context-menu-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-md",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</ContextMenuPrimitive.Portal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuItem({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
variant = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
|
||||||
|
inset?: boolean;
|
||||||
|
variant?: "default" | "destructive";
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.Item
|
||||||
|
data-slot="context-menu-item"
|
||||||
|
data-inset={inset}
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive-foreground data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/40 data-[variant=destructive]:focus:text-destructive-foreground data-[variant=destructive]:*:[svg]:!text-destructive-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuCheckboxItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
checked,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.CheckboxItem
|
||||||
|
data-slot="context-menu-checkbox-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
|
<ContextMenuPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon className="size-4" />
|
||||||
|
</ContextMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</ContextMenuPrimitive.CheckboxItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuRadioItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.RadioItem
|
||||||
|
data-slot="context-menu-radio-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
|
<ContextMenuPrimitive.ItemIndicator>
|
||||||
|
<CircleIcon className="size-2 fill-current" />
|
||||||
|
</ContextMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</ContextMenuPrimitive.RadioItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuLabel({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
|
||||||
|
inset?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.Label
|
||||||
|
data-slot="context-menu-label"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"text-foreground px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.Separator
|
||||||
|
data-slot="context-menu-separator"
|
||||||
|
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuShortcut({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="context-menu-shortcut"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
ContextMenu,
|
||||||
|
ContextMenuTrigger,
|
||||||
|
ContextMenuContent,
|
||||||
|
ContextMenuItem,
|
||||||
|
ContextMenuCheckboxItem,
|
||||||
|
ContextMenuRadioItem,
|
||||||
|
ContextMenuLabel,
|
||||||
|
ContextMenuSeparator,
|
||||||
|
ContextMenuShortcut,
|
||||||
|
ContextMenuGroup,
|
||||||
|
ContextMenuPortal,
|
||||||
|
ContextMenuSub,
|
||||||
|
ContextMenuSubContent,
|
||||||
|
ContextMenuSubTrigger,
|
||||||
|
ContextMenuRadioGroup,
|
||||||
|
};
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { Column } from "@tanstack/react-table";
|
||||||
|
import { ArrowDown, ArrowUp, ChevronsUpDown, EyeOff } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
import { Button } from "@/shared/common/ui/button";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/shared/common/ui/dropdown-menu";
|
||||||
|
|
||||||
|
interface DataTableColumnHeaderProps<TData, TValue>
|
||||||
|
extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
column: Column<TData, TValue>;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DataTableColumnHeader<TData, TValue>({
|
||||||
|
column,
|
||||||
|
title,
|
||||||
|
className,
|
||||||
|
}: DataTableColumnHeaderProps<TData, TValue>) {
|
||||||
|
if (!column.getCanSort()) {
|
||||||
|
return <div className="bg-gray-50 dark:bg-gray-800 text-base text-gray-500 dark:text-gray-400">{title}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("flex items-center space-x-2", className)}>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="-ml-3 h-8 data-[state=open]:bg-accent dark:data-[state=open]:bg-gray-700"
|
||||||
|
>
|
||||||
|
<span className="bg-gray-50 dark:bg-gray-800 text-base text-gray-500 dark:text-gray-400">{title}</span>
|
||||||
|
{column.getIsSorted() === "desc" ? (
|
||||||
|
<ArrowDown className="h-4 w-4 dark:text-gray-400" />
|
||||||
|
) : column.getIsSorted() === "asc" ? (
|
||||||
|
<ArrowUp className="h-4 w-4 dark:text-gray-400" />
|
||||||
|
) : (
|
||||||
|
<ChevronsUpDown className="h-4 w-4 dark:text-gray-400" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start" className="dark:bg-gray-800 dark:border-gray-700">
|
||||||
|
<DropdownMenuItem onClick={() => column.toggleSorting(false)} className="dark:text-gray-200 dark:hover:bg-gray-700">
|
||||||
|
<ArrowUp className="h-3.5 w-3.5 text-muted-foreground/70 dark:text-gray-400" />
|
||||||
|
Asc
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => column.toggleSorting(true)} className="dark:text-gray-200 dark:hover:bg-gray-700">
|
||||||
|
<ArrowDown className="h-3.5 w-3.5 text-muted-foreground/70 dark:text-gray-400" />
|
||||||
|
Desc
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator className="dark:bg-gray-700" />
|
||||||
|
<DropdownMenuItem onClick={() => column.toggleVisibility(false)} className="dark:text-gray-200 dark:hover:bg-gray-700">
|
||||||
|
<EyeOff className="h-3.5 w-3.5 text-muted-foreground/70 dark:text-gray-400" />
|
||||||
|
Hide
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { Table } from "@tanstack/react-table";
|
||||||
|
import {
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
ChevronsLeft,
|
||||||
|
ChevronsRight,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import { Button } from "@/shared/common/ui/button";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/shared/common/ui/select";
|
||||||
|
import React from "react";
|
||||||
|
interface DataTablePaginationProps<TData> {
|
||||||
|
table: Table<TData>;
|
||||||
|
pageCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DataTablePagination<TData>({
|
||||||
|
table,
|
||||||
|
pageCount
|
||||||
|
|
||||||
|
}: DataTablePaginationProps<TData>) {
|
||||||
|
const handlePageSizeChange = React.useCallback(
|
||||||
|
(value: string) => {
|
||||||
|
table.setPageSize(Number(value));
|
||||||
|
},
|
||||||
|
[table]
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-end px-2 my-4">
|
||||||
|
<div className="flex items-center space-x-6 lg:space-x-8">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<p className="text-sm font-medium">Rows per page</p>
|
||||||
|
<Select
|
||||||
|
value={`${table.getState().pagination.pageSize}`}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
table.setPageSize(Number(value));
|
||||||
|
}}>
|
||||||
|
<SelectTrigger className="h-8 w-[70px]">
|
||||||
|
<SelectValue placeholder={table.getState().pagination.pageSize} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent side="top">
|
||||||
|
{[10, 20, 30, 40, 50].map((pageSize) => (
|
||||||
|
<SelectItem key={pageSize} value={`${pageSize}`}>
|
||||||
|
{pageSize}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex w-[100px] items-center justify-center text-sm font-medium">
|
||||||
|
Page {table.getState().pagination.pageIndex + 1} of{" "}
|
||||||
|
{table.getPageCount()}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="hidden h-8 w-8 p-0 lg:flex"
|
||||||
|
onClick={() => {
|
||||||
|
table.setPageIndex(0);
|
||||||
|
}}
|
||||||
|
disabled={!table.getCanPreviousPage()}>
|
||||||
|
<span className="sr-only">Go to first page</span>
|
||||||
|
<ChevronsLeft />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
onClick={() => {
|
||||||
|
table.previousPage();
|
||||||
|
}}
|
||||||
|
disabled={!table.getCanPreviousPage()}>
|
||||||
|
<span className="sr-only">Go to previous page</span>
|
||||||
|
<ChevronLeft />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
onClick={() => {
|
||||||
|
table.nextPage();
|
||||||
|
}}
|
||||||
|
disabled={!table.getCanNextPage()}>
|
||||||
|
<span className="sr-only">Go to next page</span>
|
||||||
|
<ChevronRight />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="hidden h-8 w-8 p-0 lg:flex"
|
||||||
|
onClick={() => {
|
||||||
|
const lastPageIndex = table.getPageCount() - 1;
|
||||||
|
table.setPageIndex(lastPageIndex);
|
||||||
|
}}
|
||||||
|
disabled={!table.getCanNextPage()}>
|
||||||
|
<span className="sr-only">Go to last page</span>
|
||||||
|
<ChevronsRight />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { Table } from "@tanstack/react-table";
|
||||||
|
import {
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
ChevronsLeft,
|
||||||
|
ChevronsRight,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import { Button } from "@/shared/common/ui/button";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/shared/common/ui/select";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
interface DataTablePaginationProps<TData> {
|
||||||
|
table: Table<TData>;
|
||||||
|
pageCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DataTablePagination<TData>({
|
||||||
|
table,
|
||||||
|
}: DataTablePaginationProps<TData>) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 py-2">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<p className="text-xs sm:text-sm font-medium whitespace-nowrap">
|
||||||
|
Rows per page
|
||||||
|
</p>
|
||||||
|
<Select
|
||||||
|
value={`${table.getState().pagination?.pageSize ?? 10}`}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
table.setPageSize(Number(value));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-9 sm:h-8 w-[70px] min-w-[70px] text-sm">
|
||||||
|
<SelectValue
|
||||||
|
placeholder={table.getState().pagination?.pageSize ?? 10}
|
||||||
|
/>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent side="top">
|
||||||
|
{[10, 20, 30, 40, 50].map((pageSize) => (
|
||||||
|
<SelectItem key={pageSize} value={`${pageSize}`}>
|
||||||
|
{pageSize}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex w-full sm:w-auto items-center justify-between sm:justify-end gap-2 sm:gap-4">
|
||||||
|
<div className="flex items-center justify-center text-xs sm:text-sm font-medium whitespace-nowrap">
|
||||||
|
Page {table.getState().pagination.pageIndex + 1} of{" "}
|
||||||
|
{table.getPageCount()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-1 sm:space-x-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="h-9 w-9 p-0 sm:hidden"
|
||||||
|
onClick={() => {
|
||||||
|
table.setPageIndex(0);
|
||||||
|
}}
|
||||||
|
disabled={!table.getCanPreviousPage()}
|
||||||
|
>
|
||||||
|
<span className="sr-only">Go to first page</span>
|
||||||
|
<ChevronsLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="hidden h-9 sm:h-8 w-9 sm:w-8 p-0 sm:flex"
|
||||||
|
onClick={() => {
|
||||||
|
table.setPageIndex(0);
|
||||||
|
}}
|
||||||
|
disabled={!table.getCanPreviousPage()}
|
||||||
|
>
|
||||||
|
<span className="sr-only">Go to first page</span>
|
||||||
|
<ChevronsLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="h-9 sm:h-8 w-9 sm:w-8 p-0"
|
||||||
|
onClick={() => {
|
||||||
|
table.previousPage();
|
||||||
|
}}
|
||||||
|
disabled={!table.getCanPreviousPage()}
|
||||||
|
>
|
||||||
|
<span className="sr-only">Go to previous page</span>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="h-9 sm:h-8 w-9 sm:w-8 p-0"
|
||||||
|
onClick={() => {
|
||||||
|
table.nextPage();
|
||||||
|
}}
|
||||||
|
disabled={!table.getCanNextPage()}
|
||||||
|
>
|
||||||
|
<span className="sr-only">Go to next page</span>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="h-9 w-9 p-0 sm:hidden"
|
||||||
|
onClick={() => {
|
||||||
|
const lastPageIndex = table.getPageCount() - 1;
|
||||||
|
table.setPageIndex(lastPageIndex);
|
||||||
|
}}
|
||||||
|
disabled={!table.getCanNextPage()}
|
||||||
|
>
|
||||||
|
<span className="sr-only">Go to last page</span>
|
||||||
|
<ChevronsRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="hidden h-9 sm:h-8 w-9 sm:w-8 p-0 sm:flex"
|
||||||
|
onClick={() => {
|
||||||
|
const lastPageIndex = table.getPageCount() - 1;
|
||||||
|
table.setPageIndex(lastPageIndex);
|
||||||
|
}}
|
||||||
|
disabled={!table.getCanNextPage()}
|
||||||
|
>
|
||||||
|
<span className="sr-only">Go to last page</span>
|
||||||
|
<ChevronsRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||||
|
import { XIcon } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
|
||||||
|
function Dialog({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||||
|
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||||
|
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||||
|
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogClose({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||||
|
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
data-slot="dialog-overlay"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<DialogPortal data-slot="dialog-portal">
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
data-slot="dialog-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}>
|
||||||
|
{children}
|
||||||
|
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
|
||||||
|
<XIcon />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-header"
|
||||||
|
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-footer"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
data-slot="dialog-title"
|
||||||
|
className={cn("text-lg leading-none font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
data-slot="dialog-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogPortal,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
};
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import * as Dialog from "@radix-ui/react-dialog";
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
|
||||||
|
export const Drawer = Dialog.Root;
|
||||||
|
export const DrawerTrigger = Dialog.Trigger;
|
||||||
|
export const DrawerContent = ({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<Dialog.Portal>
|
||||||
|
<Dialog.Overlay className="fixed inset-0 bg-black/50" />
|
||||||
|
<Dialog.Content
|
||||||
|
className={cn(
|
||||||
|
"fixed right-0 top-0 h-full w-[400px] bg-white p-6 shadow-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}>
|
||||||
|
{children}
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Portal>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const DrawerHeader: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}) => (
|
||||||
|
<div className={cn("mb-4", className)} {...props}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const DrawerTitle = Dialog.Title;
|
||||||
|
export const DrawerDescription = Dialog.Description;
|
||||||
|
|
||||||
|
export const DrawerFooter: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}) => (
|
||||||
|
<div className={cn("mt-4 flex justify-end gap-2", className)} {...props}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const DrawerClose = Dialog.Close;
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||||
|
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
|
||||||
|
function DropdownMenu({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||||
|
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Trigger
|
||||||
|
data-slot="dropdown-menu-trigger"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuContent({
|
||||||
|
className,
|
||||||
|
sideOffset = 4,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Portal>
|
||||||
|
<DropdownMenuPrimitive.Content
|
||||||
|
data-slot="dropdown-menu-content"
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-md",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</DropdownMenuPrimitive.Portal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuItem({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
variant = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||||
|
inset?: boolean;
|
||||||
|
variant?: "default" | "destructive";
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Item
|
||||||
|
data-slot="dropdown-menu-item"
|
||||||
|
data-inset={inset}
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive-foreground data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/40 data-[variant=destructive]:focus:text-destructive-foreground data-[variant=destructive]:*:[svg]:!text-destructive-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuCheckboxItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
checked,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
|
data-slot="dropdown-menu-checkbox-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon className="size-4" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuRadioGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.RadioGroup
|
||||||
|
data-slot="dropdown-menu-radio-group"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuRadioItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.RadioItem
|
||||||
|
data-slot="dropdown-menu-radio-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<CircleIcon className="size-2 fill-current" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.RadioItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuLabel({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||||
|
inset?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Label
|
||||||
|
data-slot="dropdown-menu-label"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Separator
|
||||||
|
data-slot="dropdown-menu-separator"
|
||||||
|
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuShortcut({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="dropdown-menu-shortcut"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSub({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||||
|
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSubTrigger({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||||
|
inset?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
|
data-slot="dropdown-menu-sub-trigger"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRightIcon className="ml-auto size-4" />
|
||||||
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSubContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.SubContent
|
||||||
|
data-slot="dropdown-menu-sub-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
};
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { ImageIcon } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
import { Card, CardDescription, CardTitle } from "@/shared/common/ui/card";
|
||||||
|
|
||||||
|
interface EmptyCardProps extends React.ComponentPropsWithoutRef<typeof Card> {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
action?: React.ReactNode;
|
||||||
|
icon?: React.ComponentType<{ className?: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmptyCard({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
icon: Icon = ImageIcon,
|
||||||
|
action,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: EmptyCardProps) {
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
className={cn(
|
||||||
|
"flex w-full flex-col items-center justify-center space-y-6 bg-transparent p-16",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className="mr-4 shrink-0 rounded-full border border-dashed p-4">
|
||||||
|
<Icon className="size-8 text-muted-foreground" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center gap-1.5 text-center">
|
||||||
|
<CardTitle>{title}</CardTitle>
|
||||||
|
{description ? <CardDescription>{description}</CardDescription> : null}
|
||||||
|
</div>
|
||||||
|
{action ? action : null}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,430 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { FileText, Upload, X } from "lucide-react";
|
||||||
|
import Dropzone, {
|
||||||
|
type DropzoneProps,
|
||||||
|
type FileRejection,
|
||||||
|
} from "react-dropzone";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { Button } from "@/shared/common/ui/button";
|
||||||
|
import { Progress } from "@/shared/common/ui/progress";
|
||||||
|
import { ScrollArea } from "@/shared/common/ui/scroll-area";
|
||||||
|
import { useControllableState } from "./hooks/use-controllable-state";
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
import { formatBytes } from "./utils";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { t } from "i18next";
|
||||||
|
import { ClientSideValidator } from "@/shared/services/validation/ClientSideValidator";
|
||||||
|
import { FileUploadValidator } from "@/shared/services/validation/FileUploadValidator";
|
||||||
|
|
||||||
|
interface FileUploaderProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
/**
|
||||||
|
* Value of the uploader.
|
||||||
|
* @type File[]
|
||||||
|
* @default undefined
|
||||||
|
* @example value={files}
|
||||||
|
*/
|
||||||
|
value?: File[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Function to be called when the value changes.
|
||||||
|
* @type (files: File[]) => void
|
||||||
|
* @default undefined
|
||||||
|
* @example onValueChange={(files) => setFiles(files)}
|
||||||
|
*/
|
||||||
|
onValueChange: (files: File[]) => void | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Function to be called when files are uploaded.
|
||||||
|
* @type (files: File[]) => Promise<void>
|
||||||
|
* @default undefined
|
||||||
|
* @example onUpload={(files) => uploadFiles(files)}
|
||||||
|
*/
|
||||||
|
onUpload?: (files: File[]) => Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Progress of the uploaded files.
|
||||||
|
* @type Record<string, number> | undefined
|
||||||
|
* @default undefined
|
||||||
|
* @example progresses={{ "file1.png": 50 }}
|
||||||
|
*/
|
||||||
|
progresses?: Record<string, number>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accepted file types for the uploader.
|
||||||
|
* @type { [key: string]: string[]}
|
||||||
|
* @default
|
||||||
|
* ```ts
|
||||||
|
* { "image/*": [] }
|
||||||
|
* ```
|
||||||
|
* @example accept={["image/png", "image/jpeg"]}
|
||||||
|
*/
|
||||||
|
accept?: DropzoneProps["accept"];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maximum file size for the uploader.
|
||||||
|
* @type number | undefined
|
||||||
|
* @default 1024 * 1024 * 2 // 2MB
|
||||||
|
* @example maxSize={1024 * 1024 * 2} // 2MB
|
||||||
|
*/
|
||||||
|
maxSize?: DropzoneProps["maxSize"];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maximum number of files for the uploader.
|
||||||
|
* @type number | undefined
|
||||||
|
* @default 1
|
||||||
|
* @example maxFileCount={4}
|
||||||
|
*/
|
||||||
|
maxFileCount?: DropzoneProps["maxFiles"];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the uploader should accept multiple files.
|
||||||
|
* @type boolean
|
||||||
|
* @default false
|
||||||
|
* @example multiple
|
||||||
|
*/
|
||||||
|
multiple?: boolean;
|
||||||
|
canUploadIndividual?: boolean;
|
||||||
|
uploadIndividualFun?: () => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the uploader is disabled.
|
||||||
|
* @type boolean
|
||||||
|
* @default false
|
||||||
|
* @example disabled
|
||||||
|
*/
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FileUploader(props: FileUploaderProps) {
|
||||||
|
const {
|
||||||
|
value: valueProp,
|
||||||
|
onValueChange,
|
||||||
|
onUpload,
|
||||||
|
progresses,
|
||||||
|
accept = {
|
||||||
|
"/": [],
|
||||||
|
},
|
||||||
|
maxSize = 1024 * 1024 * 200,
|
||||||
|
maxFileCount = 4,
|
||||||
|
multiple = false,
|
||||||
|
disabled = false,
|
||||||
|
className,
|
||||||
|
canUploadIndividual = false,
|
||||||
|
uploadIndividualFun,
|
||||||
|
...dropzoneProps
|
||||||
|
} = props;
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [files, setFiles] = useControllableState({
|
||||||
|
prop: valueProp,
|
||||||
|
onChange: onValueChange,
|
||||||
|
});
|
||||||
|
const validatorRef = React.useRef(new ClientSideValidator());
|
||||||
|
|
||||||
|
const onDrop = React.useCallback(
|
||||||
|
async (acceptedFiles: File[], rejectedFiles: FileRejection[]) => {
|
||||||
|
if ((files?.length ?? 0) + acceptedFiles.length > maxFileCount) {
|
||||||
|
toast.error(
|
||||||
|
`${t("PDF.cannotUploadMoreThan")} ${maxFileCount} ${t("PDF.files")}`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate files using comprehensive validator
|
||||||
|
const validatedFiles: File[] = [];
|
||||||
|
const invalidFiles: string[] = [];
|
||||||
|
|
||||||
|
for (const file of acceptedFiles) {
|
||||||
|
const allowedMimeTypes = accept
|
||||||
|
? Object.keys(accept)
|
||||||
|
.map((mime) => mime.trim().toLowerCase())
|
||||||
|
.filter(
|
||||||
|
(mime) =>
|
||||||
|
mime.includes("/") &&
|
||||||
|
mime !== "/" &&
|
||||||
|
mime !== "*" &&
|
||||||
|
mime !== "*/*"
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
const allowedExtensions = accept
|
||||||
|
? Object.values(accept).flatMap((extensions) =>
|
||||||
|
(extensions || []).map((ext) =>
|
||||||
|
ext.startsWith(".") ? ext.toLowerCase() : `.${ext.toLowerCase()}`
|
||||||
|
)
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
// Basic validation (size, extension, filename, MIME type)
|
||||||
|
const basicValidation = FileUploadValidator.validateFile(file, {
|
||||||
|
maxSizeMB: maxSize ? maxSize / (1024 * 1024) : undefined,
|
||||||
|
allowedMimeTypes:
|
||||||
|
file.type && allowedMimeTypes?.length ? allowedMimeTypes : undefined,
|
||||||
|
allowedExtensions:
|
||||||
|
allowedExtensions && allowedExtensions.length > 0
|
||||||
|
? allowedExtensions
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!basicValidation.isValid) {
|
||||||
|
invalidFiles.push(`${file.name}: ${basicValidation.error}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client-side magic byte validation
|
||||||
|
const clientValidation = await validatorRef.current.validateUpload(
|
||||||
|
file,
|
||||||
|
file.type,
|
||||||
|
allowedMimeTypes || []
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!clientValidation.isValid) {
|
||||||
|
invalidFiles.push(`${file.name}: ${clientValidation.error}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
validatedFiles.push(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show errors for invalid files
|
||||||
|
if (invalidFiles.length > 0) {
|
||||||
|
invalidFiles.forEach((error) => {
|
||||||
|
toast.error(error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const newFiles = validatedFiles.map((file) =>
|
||||||
|
Object.assign(file, {
|
||||||
|
preview: URL.createObjectURL(file),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const updatedFiles = files ? [...files, ...newFiles] : newFiles;
|
||||||
|
|
||||||
|
setFiles(updatedFiles);
|
||||||
|
|
||||||
|
if (rejectedFiles.length > 0) {
|
||||||
|
rejectedFiles.forEach(({ file }) => {
|
||||||
|
toast.error(`${t("PDF.file")} ${file.name} ${t("PDF.wasRejected")}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
onUpload &&
|
||||||
|
updatedFiles.length > 0 &&
|
||||||
|
updatedFiles.length <= maxFileCount
|
||||||
|
) {
|
||||||
|
const target =
|
||||||
|
updatedFiles.length > 0 ? `${updatedFiles.length} files` : `file`;
|
||||||
|
|
||||||
|
toast.promise(onUpload(updatedFiles), {
|
||||||
|
loading: `${t("PDF.uploading")} ${target}...`,
|
||||||
|
success: () => {
|
||||||
|
setFiles([]);
|
||||||
|
return `${target} ${t("PDF.uploaded")}`;
|
||||||
|
},
|
||||||
|
error: `${t("PDF.failedToUpload")} ${target}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
[files, maxFileCount, multiple, onUpload, setFiles, t, maxSize, accept]
|
||||||
|
);
|
||||||
|
|
||||||
|
function onRemove(index: number) {
|
||||||
|
if (!files) return;
|
||||||
|
const newFiles = files.filter((_, i) => i !== index);
|
||||||
|
setFiles(newFiles);
|
||||||
|
onValueChange?.(newFiles);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revoke preview url when component unmounts
|
||||||
|
React.useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (!files) return;
|
||||||
|
files.forEach((file) => {
|
||||||
|
if (isFileWithPreview(file)) {
|
||||||
|
URL.revokeObjectURL(file.preview);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const isDisabled = disabled || (files?.length ?? 0) >= maxFileCount;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex flex-col gap-6 overflow-hidden">
|
||||||
|
{onValueChange !== null && (
|
||||||
|
<Dropzone
|
||||||
|
onDrop={onDrop}
|
||||||
|
accept={accept}
|
||||||
|
maxSize={maxSize}
|
||||||
|
maxFiles={maxFileCount}
|
||||||
|
multiple={maxFileCount > 1 || multiple}
|
||||||
|
disabled={isDisabled}
|
||||||
|
>
|
||||||
|
{({ getRootProps, getInputProps, isDragActive }) => (
|
||||||
|
<div
|
||||||
|
{...getRootProps()}
|
||||||
|
className={cn(
|
||||||
|
"group relative grid h-52 w-full cursor-pointer place-items-center rounded-lg border-2 border-dashed border-muted-foreground/25 px-5 py-2.5 text-center transition hover:bg-muted/25",
|
||||||
|
"ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||||
|
"dark:bg-gray-900/50 dark:border-gray-600 dark:hover:bg-gray-800/60",
|
||||||
|
isDragActive && "border-muted-foreground/50 dark:border-gray-500",
|
||||||
|
isDisabled && "pointer-events-none opacity-60",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...dropzoneProps}
|
||||||
|
>
|
||||||
|
<input {...getInputProps()} />
|
||||||
|
{isDragActive ? (
|
||||||
|
<div className="flex flex-col items-center justify-center gap-4 sm:px-5">
|
||||||
|
<div className="rounded-full border border-dashed border-muted-foreground/50 p-3 dark:border-gray-500">
|
||||||
|
<Upload
|
||||||
|
className="size-7 text-muted-foreground"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="font-medium text-muted-foreground">
|
||||||
|
{t("PDF.dropFilesHere")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center justify-center gap-4 sm:px-5">
|
||||||
|
<div className="rounded-full border border-dashed border-muted-foreground/50 p-3 dark:border-gray-500">
|
||||||
|
<Upload
|
||||||
|
className="size-7 text-muted-foreground"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-px">
|
||||||
|
<p className="font-medium text-muted-foreground">
|
||||||
|
{t("PDF.dragDropInstruction")}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground/70">
|
||||||
|
{t("PDF.youCanUpload")}
|
||||||
|
{maxFileCount > 1
|
||||||
|
? ` ${
|
||||||
|
maxFileCount === Infinity
|
||||||
|
? "multiple"
|
||||||
|
: maxFileCount
|
||||||
|
}
|
||||||
|
${t("PDF.filesUpTo")} ${formatBytes(maxSize)} )`
|
||||||
|
: ` ${t("PDF.aFileWith")} ${formatBytes(maxSize)}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Dropzone>
|
||||||
|
)}
|
||||||
|
{files?.length ? (
|
||||||
|
<ScrollArea className="h-fit w-full px-3">
|
||||||
|
<div className="flex max-h-48 flex-col gap-4">
|
||||||
|
{files?.map((file, index) => (
|
||||||
|
<FileCard
|
||||||
|
key={index}
|
||||||
|
file={file}
|
||||||
|
onRemove={() => onRemove(index)}
|
||||||
|
progress={progresses?.[file.name]}
|
||||||
|
canUploadIndividual={canUploadIndividual}
|
||||||
|
uploadIndividualFun={uploadIndividualFun || (() => {})}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FileCardProps {
|
||||||
|
file: File;
|
||||||
|
onRemove: () => void;
|
||||||
|
progress?: number;
|
||||||
|
canUploadIndividual?: boolean;
|
||||||
|
uploadIndividualFun: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function FileCard({
|
||||||
|
file,
|
||||||
|
progress,
|
||||||
|
onRemove,
|
||||||
|
canUploadIndividual = false,
|
||||||
|
uploadIndividualFun,
|
||||||
|
}: FileCardProps) {
|
||||||
|
return (
|
||||||
|
<div className="relative flex items-center gap-2.5 rounded-md border border-transparent p-2 dark:border-gray-700 dark:bg-gray-800/50">
|
||||||
|
<div className="flex flex-1 gap-2.5">
|
||||||
|
{isFileWithPreview(file) ? <FilePreview file={file} /> : null}
|
||||||
|
<div className="flex w-full flex-col gap-2">
|
||||||
|
<div className="flex flex-col gap-px">
|
||||||
|
<p className="line-clamp-1 text-sm font-medium text-foreground/80">
|
||||||
|
{file.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{formatBytes(file.size)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{progress ? <Progress value={progress} /> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="size-7 dark:border-gray-600 dark:hover:bg-gray-700"
|
||||||
|
onClick={onRemove}
|
||||||
|
>
|
||||||
|
<X className="size-4" aria-hidden="true" />
|
||||||
|
<span className="sr-only">{t("PDF.removeFile")}</span>
|
||||||
|
</Button>
|
||||||
|
{canUploadIndividual && (
|
||||||
|
<Button type="button" variant="outline" className="dark:border-gray-600 dark:hover:bg-gray-700" onClick={uploadIndividualFun}>
|
||||||
|
upload
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFileWithPreview(file: File): file is File & { preview: string } {
|
||||||
|
return "preview" in file && typeof file.preview === "string";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FilePreviewProps {
|
||||||
|
file: File & { preview: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
function FilePreview({ file }: FilePreviewProps) {
|
||||||
|
if (file.type.startsWith("image/")) {
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
className="h-auto max-w-full"
|
||||||
|
src={file.preview}
|
||||||
|
alt={file.name}
|
||||||
|
width={48}
|
||||||
|
height={48}
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
{
|
||||||
|
/* <Image
|
||||||
|
src={file.preview}
|
||||||
|
alt={file.name}
|
||||||
|
width={48}
|
||||||
|
height={48}
|
||||||
|
loading="lazy"
|
||||||
|
className="aspect-square shrink-0 rounded-md object-cover"
|
||||||
|
/> */
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<FileText className="size-10 text-muted-foreground" aria-hidden="true" />
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A custom hook that converts a callback to a ref to avoid triggering re-renders when passed as a
|
||||||
|
* prop or avoid re-executing effects when passed as a dependency
|
||||||
|
*/
|
||||||
|
function useCallbackRef<T extends (...args: never[]) => unknown>(
|
||||||
|
callback: T | undefined
|
||||||
|
): T {
|
||||||
|
const callbackRef = React.useRef(callback);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
callbackRef.current = callback;
|
||||||
|
});
|
||||||
|
|
||||||
|
return React.useMemo(
|
||||||
|
() => ((...args) => callbackRef.current?.(...args)) as T,
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { useCallbackRef };
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { useCallbackRef } from "./use-callback-ref";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @see https://github.com/radix-ui/primitives/blob/main/packages/react/use-controllable-state/src/useControllableState.tsx
|
||||||
|
*/
|
||||||
|
|
||||||
|
type UseControllableStateParams<T> = {
|
||||||
|
prop?: T | undefined;
|
||||||
|
defaultProp?: T | undefined;
|
||||||
|
onChange?: (state: T) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SetStateFn<T> = (prevState?: T) => T;
|
||||||
|
|
||||||
|
function useControllableState<T>({
|
||||||
|
prop,
|
||||||
|
defaultProp,
|
||||||
|
onChange = () => {},
|
||||||
|
}: UseControllableStateParams<T>) {
|
||||||
|
const [uncontrolledProp, setUncontrolledProp] = useUncontrolledState({
|
||||||
|
defaultProp,
|
||||||
|
onChange,
|
||||||
|
});
|
||||||
|
const isControlled = prop !== undefined;
|
||||||
|
const value = isControlled ? prop : uncontrolledProp;
|
||||||
|
const handleChange = useCallbackRef(onChange);
|
||||||
|
|
||||||
|
const setValue: React.Dispatch<React.SetStateAction<T | undefined>> =
|
||||||
|
React.useCallback(
|
||||||
|
(nextValue) => {
|
||||||
|
if (isControlled) {
|
||||||
|
const setter = nextValue as SetStateFn<T>;
|
||||||
|
const value =
|
||||||
|
typeof nextValue === "function" ? setter(prop) : nextValue;
|
||||||
|
if (value !== prop) handleChange(value as T);
|
||||||
|
} else {
|
||||||
|
setUncontrolledProp(nextValue);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isControlled, prop, setUncontrolledProp, handleChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
return [value, setValue] as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
function useUncontrolledState<T>({
|
||||||
|
defaultProp,
|
||||||
|
onChange,
|
||||||
|
}: Omit<UseControllableStateParams<T>, "prop">) {
|
||||||
|
const uncontrolledState = React.useState<T | undefined>(defaultProp);
|
||||||
|
const [value] = uncontrolledState;
|
||||||
|
const prevValueRef = React.useRef(value);
|
||||||
|
const handleChange = useCallbackRef(onChange);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (prevValueRef.current !== value) {
|
||||||
|
handleChange(value as T);
|
||||||
|
prevValueRef.current = value;
|
||||||
|
}
|
||||||
|
}, [value, prevValueRef, handleChange]);
|
||||||
|
|
||||||
|
return uncontrolledState;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { useControllableState };
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
// Define the structure of an uploaded file
|
||||||
|
export interface UploadedFile {
|
||||||
|
name: string;
|
||||||
|
size: number;
|
||||||
|
type: string;
|
||||||
|
buffer: ArrayBuffer;
|
||||||
|
preview: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UploadProgress {
|
||||||
|
[key: string]: number; // ✅ Use string index for compatibility
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUploadFile() {
|
||||||
|
const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([]);
|
||||||
|
const [progresses, setProgresses] = useState<UploadProgress>({});
|
||||||
|
const [isUploading, setIsUploading] = useState<boolean>(false);
|
||||||
|
|
||||||
|
// Accepts File[] instead of FileList
|
||||||
|
const onUpload = async (files: File[]) => {
|
||||||
|
setIsUploading(true);
|
||||||
|
|
||||||
|
const filePromises: Promise<UploadedFile>[] = files.map((file, index) => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
const previewReader = new FileReader();
|
||||||
|
reader.onload = (event) => {
|
||||||
|
if (event.target?.result instanceof ArrayBuffer) {
|
||||||
|
previewReader.readAsDataURL(file); // Convert for preview
|
||||||
|
|
||||||
|
previewReader.onload = () => {
|
||||||
|
resolve({
|
||||||
|
name: file.name,
|
||||||
|
size: file.size,
|
||||||
|
type: file.type,
|
||||||
|
buffer: event.target!.result as ArrayBuffer,
|
||||||
|
preview: previewReader.result as string, // Store preview
|
||||||
|
});
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
reject(new Error("Failed to read file as ArrayBuffer."));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
reader.onerror = () => reject(new Error("File reading error"));
|
||||||
|
reader.readAsArrayBuffer(file);
|
||||||
|
|
||||||
|
// Convert index to string for progresses
|
||||||
|
const fileIndex = index.toString();
|
||||||
|
setProgresses((prev) => ({ ...prev, [fileIndex]: 0 }));
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
setProgresses((prev) => ({ ...prev, [fileIndex]: 100 }));
|
||||||
|
}, 1000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fileBuffers = await Promise.all(filePromises);
|
||||||
|
setUploadedFiles((prev) => [...prev, ...fileBuffers]);
|
||||||
|
} catch (error) {
|
||||||
|
} finally {
|
||||||
|
setIsUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
onUpload,
|
||||||
|
progresses,
|
||||||
|
uploadedFiles,
|
||||||
|
isUploading,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"use client";
|
||||||
|
import { FileUploader } from "./fileUpload";
|
||||||
|
import { useState, useEffect, useRef } from "react"; // Added useRef import
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
export interface FileInfo {
|
||||||
|
fileName: string;
|
||||||
|
contentType: string;
|
||||||
|
originalname: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Name {
|
||||||
|
am: string;
|
||||||
|
en: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UploadRequestBody {
|
||||||
|
fileInfo: FileInfo;
|
||||||
|
name: Name;
|
||||||
|
recordContentId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UploadResponse {
|
||||||
|
uploadUrl: string;
|
||||||
|
attachmentId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReusableFileUploader({
|
||||||
|
setFiles,
|
||||||
|
files,
|
||||||
|
canUploadIndividual = false,
|
||||||
|
isLoading,
|
||||||
|
uploadIndividualFun,
|
||||||
|
}: {
|
||||||
|
setFiles?: (files: File[]) => void;
|
||||||
|
files: File[];
|
||||||
|
isLoading: boolean;
|
||||||
|
canUploadIndividual?: boolean;
|
||||||
|
uploadIndividualFun?: () => void;
|
||||||
|
}) {
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const lastFiles = useRef<File[]>([]); // Now properly defined
|
||||||
|
useEffect(() => {
|
||||||
|
lastFiles.current = files;
|
||||||
|
}, [files]);
|
||||||
|
|
||||||
|
const handleFileChange = (newFiles: File[]) => {
|
||||||
|
if (setFiles) {
|
||||||
|
// Only update if files actually changed
|
||||||
|
if (
|
||||||
|
newFiles.length !== lastFiles.current.length ||
|
||||||
|
newFiles.some((f, i) => f !== lastFiles.current[i])
|
||||||
|
) {
|
||||||
|
setFiles(newFiles);
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<FileUploader
|
||||||
|
maxFileCount={30}
|
||||||
|
maxSize={200 * 1024 * 1024}
|
||||||
|
value={files}
|
||||||
|
onValueChange={handleFileChange}
|
||||||
|
disabled={isLoading}
|
||||||
|
canUploadIndividual={canUploadIndividual}
|
||||||
|
uploadIndividualFun={uploadIndividualFun}
|
||||||
|
/>
|
||||||
|
{isLoading && <p className="text-muted-foreground">Uploading...</p>}
|
||||||
|
{error && <p className="text-red-500 dark:text-red-400">{error}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/shared/common/ui/card";
|
||||||
|
import { ScrollArea, ScrollBar } from "@/shared/common/ui/scroll-area";
|
||||||
|
|
||||||
|
import { EmptyCard } from "./empty-card";
|
||||||
|
import { UploadedFile } from "./hooks/use-file-upload";
|
||||||
|
|
||||||
|
interface UploadedFilesCardProps {
|
||||||
|
uploadedFiles: UploadedFile[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UploadedFilesCard({ uploadedFiles }: UploadedFilesCardProps) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Uploaded files</CardTitle>
|
||||||
|
<CardDescription>View the uploaded files here</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{uploadedFiles.length > 0 ? (
|
||||||
|
<ScrollArea className="pb-4">
|
||||||
|
<div className="flex w-max space-x-2.5">
|
||||||
|
{uploadedFiles.map((file, index) => (
|
||||||
|
<div key={index} className="relative aspect-video w-64">
|
||||||
|
<img
|
||||||
|
src={file.preview}
|
||||||
|
alt={file.name}
|
||||||
|
className="rounded-md object-cover w-full h-full"
|
||||||
|
/>
|
||||||
|
{/* <Image
|
||||||
|
src={file.preview}
|
||||||
|
alt={file.name}
|
||||||
|
fill
|
||||||
|
sizes="(min-width: 640px) 640px, 100vw"
|
||||||
|
loading="lazy"
|
||||||
|
className="rounded-md object-cover"
|
||||||
|
/> */}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<ScrollBar orientation="horizontal" />
|
||||||
|
</ScrollArea>
|
||||||
|
) : (
|
||||||
|
<EmptyCard
|
||||||
|
title="No files uploaded"
|
||||||
|
description="Upload some files to see them here"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatBytes(
|
||||||
|
bytes: number,
|
||||||
|
opts: {
|
||||||
|
decimals?: number;
|
||||||
|
sizeType?: "accurate" | "normal";
|
||||||
|
} = {}
|
||||||
|
) {
|
||||||
|
const { decimals = 0, sizeType = "normal" } = opts;
|
||||||
|
|
||||||
|
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
|
||||||
|
const accurateSizes = ["Bytes", "KiB", "MiB", "GiB", "TiB"];
|
||||||
|
if (bytes === 0) return "0 Byte";
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||||
|
return `${(bytes / Math.pow(1024, i)).toFixed(decimals)} ${
|
||||||
|
sizeType === "accurate" ? accurateSizes[i] ?? "Bytes" : sizes[i] ?? "Bytes"
|
||||||
|
}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stole this from the @radix-ui/primitive
|
||||||
|
* @see https://github.com/radix-ui/primitives/blob/main/packages/core/primitive/src/primitive.tsx
|
||||||
|
*/
|
||||||
|
export function composeEventHandlers<E>(
|
||||||
|
originalEventHandler?: (event: E) => void,
|
||||||
|
ourEventHandler?: (event: E) => void,
|
||||||
|
{ checkForDefaultPrevented = true } = {}
|
||||||
|
) {
|
||||||
|
return function handleEvent(event: E) {
|
||||||
|
originalEventHandler?.(event);
|
||||||
|
|
||||||
|
if (
|
||||||
|
checkForDefaultPrevented === false ||
|
||||||
|
!(event as unknown as Event).defaultPrevented
|
||||||
|
) {
|
||||||
|
return ourEventHandler?.(event);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||||
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
FormProvider,
|
||||||
|
useFormContext,
|
||||||
|
useFormState,
|
||||||
|
type ControllerProps,
|
||||||
|
type FieldPath,
|
||||||
|
type FieldValues,
|
||||||
|
} from "react-hook-form";
|
||||||
|
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
import { Label } from "@/shared/common/ui/label";
|
||||||
|
|
||||||
|
const Form = FormProvider;
|
||||||
|
|
||||||
|
type FormFieldContextValue<
|
||||||
|
TFieldValues extends FieldValues = FieldValues,
|
||||||
|
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||||
|
> = {
|
||||||
|
name: TName;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||||
|
{} as FormFieldContextValue
|
||||||
|
);
|
||||||
|
|
||||||
|
const FormField = <
|
||||||
|
TFieldValues extends FieldValues = FieldValues,
|
||||||
|
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||||
|
>({
|
||||||
|
...props
|
||||||
|
}: ControllerProps<TFieldValues, TName>) => {
|
||||||
|
return (
|
||||||
|
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||||
|
<Controller {...props} />
|
||||||
|
</FormFieldContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const useFormField = () => {
|
||||||
|
const fieldContext = React.useContext(FormFieldContext);
|
||||||
|
const itemContext = React.useContext(FormItemContext);
|
||||||
|
const { getFieldState } = useFormContext();
|
||||||
|
const formState = useFormState({ name: fieldContext.name });
|
||||||
|
const fieldState = getFieldState(fieldContext.name, formState);
|
||||||
|
|
||||||
|
if (!fieldContext) {
|
||||||
|
throw new Error("useFormField should be used within <FormField>");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = itemContext;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: fieldContext.name,
|
||||||
|
formItemId: `${id}-form-item`,
|
||||||
|
formDescriptionId: `${id}-form-item-description`,
|
||||||
|
formMessageId: `${id}-form-item-message`,
|
||||||
|
...fieldState,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type FormItemContextValue = {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||||
|
{} as FormItemContextValue
|
||||||
|
);
|
||||||
|
|
||||||
|
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
const id = React.useId();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormItemContext.Provider value={{ id }}>
|
||||||
|
<div
|
||||||
|
data-slot="form-item"
|
||||||
|
className={cn("grid gap-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</FormItemContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormLabel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||||
|
const { error, formItemId } = useFormField();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Label
|
||||||
|
data-slot="form-label"
|
||||||
|
data-error={!!error}
|
||||||
|
className={cn("data-[error=true]:text-destructive-foreground", className)}
|
||||||
|
htmlFor={formItemId}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
|
||||||
|
const { error, formItemId, formDescriptionId, formMessageId } =
|
||||||
|
useFormField();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Slot
|
||||||
|
data-slot="form-control"
|
||||||
|
id={formItemId}
|
||||||
|
aria-describedby={
|
||||||
|
!error
|
||||||
|
? `${formDescriptionId}`
|
||||||
|
: `${formDescriptionId} ${formMessageId}`
|
||||||
|
}
|
||||||
|
aria-invalid={!!error}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||||
|
const { formDescriptionId } = useFormField();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
data-slot="form-description"
|
||||||
|
id={formDescriptionId}
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
|
||||||
|
const { error, formMessageId } = useFormField();
|
||||||
|
const body = error ? String(error?.message ?? "") : props.children;
|
||||||
|
|
||||||
|
if (!body) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
data-slot="form-message"
|
||||||
|
id={formMessageId}
|
||||||
|
className={cn("text-destructive-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{body}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
useFormField,
|
||||||
|
Form,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormMessage,
|
||||||
|
FormField,
|
||||||
|
};
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { Button } from "@/shared/common/ui/button";
|
||||||
|
import { Label } from "@/shared/common/ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/shared/common/ui/select";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/shared/common/ui/popover";
|
||||||
|
import { CalendarIcon } from "lucide-react";
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
import HabeshaCalendarUtils, {
|
||||||
|
HabeshaDate,
|
||||||
|
} from "@/shared/utils/habeshaCalendar";
|
||||||
|
|
||||||
|
export interface HabeshaDatePickerProps {
|
||||||
|
value?: Date;
|
||||||
|
onChange?: (date: Date) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
language?: "am" | "en";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HabeshaDatePicker: React.FC<HabeshaDatePickerProps> = ({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder = "Select date",
|
||||||
|
disabled = false,
|
||||||
|
className,
|
||||||
|
language = "am",
|
||||||
|
}) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [selectedYear, setSelectedYear] = useState<string>("");
|
||||||
|
const [selectedMonth, setSelectedMonth] = useState<string>("");
|
||||||
|
const [selectedDay, setSelectedDay] = useState<string>("");
|
||||||
|
const [displayText, setDisplayText] = useState<string>("");
|
||||||
|
|
||||||
|
// Initialize with current date or provided value
|
||||||
|
useEffect(() => {
|
||||||
|
if (value && value instanceof Date && !isNaN(value.getTime())) {
|
||||||
|
const habeshaDate = HabeshaCalendarUtils.gregorianToHabesha(value);
|
||||||
|
setSelectedYear(habeshaDate.year.toString());
|
||||||
|
setSelectedMonth(habeshaDate.month.toString());
|
||||||
|
setSelectedDay(habeshaDate.day.toString());
|
||||||
|
} else {
|
||||||
|
const current = HabeshaCalendarUtils.getCurrentHabeshaDate();
|
||||||
|
setSelectedYear(current.year.toString());
|
||||||
|
setSelectedMonth(current.month.toString());
|
||||||
|
setSelectedDay("");
|
||||||
|
}
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
// Update display text when language or value changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (value && value instanceof Date && !isNaN(value.getTime())) {
|
||||||
|
setDisplayText(HabeshaCalendarUtils.formatForDisplay(value, language));
|
||||||
|
} else {
|
||||||
|
setDisplayText("");
|
||||||
|
}
|
||||||
|
}, [value, language]);
|
||||||
|
|
||||||
|
const yearOptions = HabeshaCalendarUtils.generateHabeshaYearOptions(10, 5);
|
||||||
|
const monthOptions = React.useMemo(
|
||||||
|
() => HabeshaCalendarUtils.getMonthOptions(language),
|
||||||
|
[language]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Generate day options based on selected month and year
|
||||||
|
const getDayOptions = () => {
|
||||||
|
if (!selectedYear || !selectedMonth) return [];
|
||||||
|
|
||||||
|
const year = parseInt(selectedYear);
|
||||||
|
const month = parseInt(selectedMonth);
|
||||||
|
const daysInMonth = HabeshaCalendarUtils.getDaysInHabeshaMonth(year, month);
|
||||||
|
|
||||||
|
return Array.from({ length: daysInMonth }, (_, i) => ({
|
||||||
|
label: (i + 1).toString(),
|
||||||
|
value: (i + 1).toString(),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDateSelect = () => {
|
||||||
|
if (selectedYear && selectedMonth && selectedDay) {
|
||||||
|
const year = parseInt(selectedYear);
|
||||||
|
const month = parseInt(selectedMonth);
|
||||||
|
const day = parseInt(selectedDay);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const gregorianDate = HabeshaCalendarUtils.habeshaToGregorian(
|
||||||
|
year,
|
||||||
|
month,
|
||||||
|
day
|
||||||
|
);
|
||||||
|
onChange?.(gregorianDate);
|
||||||
|
setIsOpen(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Invalid Habesha date:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClear = () => {
|
||||||
|
setSelectedYear("");
|
||||||
|
setSelectedMonth("");
|
||||||
|
setSelectedDay("");
|
||||||
|
onChange?.(new Date()); // Reset to current date
|
||||||
|
};
|
||||||
|
|
||||||
|
const displayValue = displayText;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
"w-full justify-start text-left font-normal",
|
||||||
|
!value && "text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||||
|
{displayValue || placeholder}
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-auto p-4" align="start">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h4 className="font-medium">
|
||||||
|
{language === "am" ? "ቀን ይምረጡ" : "Select Date"}
|
||||||
|
</h4>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleClear}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
{language === "am" ? "አጽዳ" : "Clear"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{/* Year Selection */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs">
|
||||||
|
{language === "am" ? "ዓመት" : "Year"}
|
||||||
|
</Label>
|
||||||
|
<Select value={selectedYear} onValueChange={setSelectedYear}>
|
||||||
|
<SelectTrigger className="h-8">
|
||||||
|
<SelectValue
|
||||||
|
placeholder={language === "am" ? "ዓመት" : "Year"}
|
||||||
|
/>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{yearOptions.map((year) => (
|
||||||
|
<SelectItem key={year.value} value={year.value}>
|
||||||
|
{year.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Month Selection */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs">
|
||||||
|
{language === "am" ? "ወር" : "Month"}
|
||||||
|
</Label>
|
||||||
|
<Select value={selectedMonth} onValueChange={setSelectedMonth}>
|
||||||
|
<SelectTrigger className="h-8">
|
||||||
|
<SelectValue
|
||||||
|
placeholder={language === "am" ? "ወር" : "Month"}
|
||||||
|
/>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{monthOptions.map((month) => (
|
||||||
|
<SelectItem key={month.value} value={month.value}>
|
||||||
|
{month.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Day Selection */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs">
|
||||||
|
{language === "am" ? "ቀን" : "Day"}
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={selectedDay}
|
||||||
|
onValueChange={setSelectedDay}
|
||||||
|
disabled={!selectedYear || !selectedMonth}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8">
|
||||||
|
<SelectValue placeholder={language === "am" ? "ቀን" : "Day"} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{getDayOptions().map((day) => (
|
||||||
|
<SelectItem key={day.value} value={day.value}>
|
||||||
|
{day.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
>
|
||||||
|
{language === "am" ? "ሰርዝ" : "Cancel"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleDateSelect}
|
||||||
|
disabled={!selectedYear || !selectedMonth || !selectedDay}
|
||||||
|
>
|
||||||
|
{language === "am" ? "ምረጥ" : "Select"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HabeshaDatePicker;
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import * as HoverCardPrimitive from "@radix-ui/react-hover-card";
|
||||||
|
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
|
||||||
|
function HoverCard({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
|
||||||
|
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function HoverCardTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HoverCardContent({
|
||||||
|
className,
|
||||||
|
align = "center",
|
||||||
|
sideOffset = 4,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<HoverCardPrimitive.Content
|
||||||
|
data-slot="hover-card-content"
|
||||||
|
align={align}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 rounded-md border p-4 shadow-md outline-hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { HoverCard, HoverCardTrigger, HoverCardContent };
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
|
||||||
|
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||||
|
({ className, type, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
data-slot="input"
|
||||||
|
className={cn(
|
||||||
|
"border-input file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground flex h-9 w-full min-w-0 rounded-md border bg-background px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input dark:text-white dark:placeholder:text-muted-foreground/70",
|
||||||
|
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||||
|
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Input.displayName = "Input";
|
||||||
|
|
||||||
|
export { Input };
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||||
|
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
|
||||||
|
function Label({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
data-slot="label"
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Label };
|
||||||
@@ -0,0 +1,427 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
import { CheckIcon, ChevronDown, XIcon, WandSparkles } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
import { Separator } from "@/shared/common/ui/separator";
|
||||||
|
import { Button } from "@/shared/common/ui/button";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/shared/common/ui/popover";
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
CommandSeparator,
|
||||||
|
} from "@/shared/common/ui/command";
|
||||||
|
import { t } from "i18next";
|
||||||
|
|
||||||
|
const normalizeSearchText = (value: string) =>
|
||||||
|
value.normalize("NFKC").toLocaleLowerCase().replace(/\s+/g, " ").trim();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Variants for the multi-select component to handle different styles.
|
||||||
|
* Uses class-variance-authority (cva) to define different styles based on "variant" prop.
|
||||||
|
*/
|
||||||
|
const multiSelectVariants = cva(
|
||||||
|
"m-1 transition ease-in-out delay-150 hover:-translate-y-1 hover:scale-110 duration-300",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"border-foreground/10 text-foreground bg-card hover:bg-card/80",
|
||||||
|
secondary:
|
||||||
|
"border-foreground/10 bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
destructive:
|
||||||
|
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||||
|
inverted: "inverted",
|
||||||
|
dropDown:
|
||||||
|
"w-full border border-foreground/10 text-foreground bg-card hover:bg-card/80 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 transition duration-200",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props for MultiSelect component
|
||||||
|
*/
|
||||||
|
interface MultiSelectProps
|
||||||
|
extends
|
||||||
|
React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof multiSelectVariants> {
|
||||||
|
/**
|
||||||
|
* An array of option objects to be displayed in the multi-select component.
|
||||||
|
* Each option object has a label, value, and an optional icon.
|
||||||
|
*/
|
||||||
|
options: {
|
||||||
|
/** The text to display for the option. */
|
||||||
|
label: string;
|
||||||
|
/** The unique value associated with the option. */
|
||||||
|
value: string;
|
||||||
|
/** Optional icon component to display alongside the option. */
|
||||||
|
icon?: React.ComponentType<{ className?: string }>;
|
||||||
|
}[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback function triggered when the selected values change.
|
||||||
|
* Receives an array of the new selected values.
|
||||||
|
*/
|
||||||
|
onValueChange: (value: string[]) => void;
|
||||||
|
|
||||||
|
/** The default selected values when the component mounts. */
|
||||||
|
defaultValue?: string[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Placeholder text to be displayed when no values are selected.
|
||||||
|
* Optional, defaults to "Select options".
|
||||||
|
*/
|
||||||
|
placeholder?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Animation duration in seconds for the visual effects (e.g., bouncing badges).
|
||||||
|
* Optional, defaults to 0 (no animation).
|
||||||
|
*/
|
||||||
|
animation?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maximum number of items to display. Extra selected items will be summarized.
|
||||||
|
* Optional, defaults to 3.
|
||||||
|
*/
|
||||||
|
maxCount?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The modality of the popover. When set to true, interaction with outside elements
|
||||||
|
* will be disabled and only popover content will be visible to screen readers.
|
||||||
|
* Optional, defaults to false.
|
||||||
|
*/
|
||||||
|
modalPopover?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If true, renders the multi-select component as a child of another component.
|
||||||
|
* Optional, defaults to false.
|
||||||
|
*/
|
||||||
|
asChild?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Additional class names to apply custom styles to the multi-select component.
|
||||||
|
* Optional, can be used to add custom styles.
|
||||||
|
*/
|
||||||
|
className?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Current selected values
|
||||||
|
*/
|
||||||
|
value?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MultiSelect = React.forwardRef<
|
||||||
|
HTMLButtonElement,
|
||||||
|
MultiSelectProps
|
||||||
|
>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
options = [],
|
||||||
|
onValueChange,
|
||||||
|
variant,
|
||||||
|
defaultValue = [],
|
||||||
|
placeholder = "Select options",
|
||||||
|
animation = 0,
|
||||||
|
maxCount = 3,
|
||||||
|
modalPopover = false,
|
||||||
|
asChild = false,
|
||||||
|
className,
|
||||||
|
value,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
) => {
|
||||||
|
const [selectedValues, setSelectedValues] = React.useState<string[]>(
|
||||||
|
value || defaultValue,
|
||||||
|
);
|
||||||
|
const [isPopoverOpen, setIsPopoverOpen] = React.useState(false);
|
||||||
|
const [isAnimating, setIsAnimating] = React.useState(false);
|
||||||
|
const [isExtraOpen, setIsExtraOpen] = React.useState(false);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (value !== undefined) {
|
||||||
|
setSelectedValues(value);
|
||||||
|
}
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
const handleInputKeyDown = (
|
||||||
|
event: React.KeyboardEvent<HTMLInputElement>,
|
||||||
|
) => {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
setIsPopoverOpen(true);
|
||||||
|
} else if (event.key === "Backspace" && !event.currentTarget.value) {
|
||||||
|
const newSelectedValues = [...selectedValues];
|
||||||
|
newSelectedValues.pop();
|
||||||
|
setSelectedValues(newSelectedValues);
|
||||||
|
onValueChange(newSelectedValues);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleOption = (option: string) => {
|
||||||
|
const newSelectedValues = selectedValues.includes(option)
|
||||||
|
? selectedValues.filter((value) => value !== option)
|
||||||
|
: [...selectedValues, option];
|
||||||
|
setSelectedValues(newSelectedValues);
|
||||||
|
onValueChange(newSelectedValues);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Function to remove a specific item without opening the dropdown
|
||||||
|
const removeItem = (valueToRemove: string, event: React.MouseEvent) => {
|
||||||
|
event.stopPropagation(); // Prevent dropdown from opening
|
||||||
|
const newSelectedValues = selectedValues.filter(
|
||||||
|
(value) => value !== valueToRemove,
|
||||||
|
);
|
||||||
|
setSelectedValues(newSelectedValues);
|
||||||
|
onValueChange(newSelectedValues);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClear = (event: React.MouseEvent) => {
|
||||||
|
event.stopPropagation(); // Prevent dropdown from opening
|
||||||
|
setSelectedValues([]);
|
||||||
|
onValueChange([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleAll = () => {
|
||||||
|
if (selectedValues.length === options.length) {
|
||||||
|
handleClear(new MouseEvent("click") as unknown as React.MouseEvent);
|
||||||
|
} else {
|
||||||
|
const allValues = options.map((option) => option.value);
|
||||||
|
setSelectedValues(allValues);
|
||||||
|
onValueChange(allValues);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover
|
||||||
|
open={isPopoverOpen}
|
||||||
|
onOpenChange={setIsPopoverOpen}
|
||||||
|
modal={modalPopover}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full p-2 rounded-md border min-h-10 h-auto items-center justify-between bg-background hover:bg-background/90 [&_svg]:pointer-events-auto",
|
||||||
|
className,
|
||||||
|
)}>
|
||||||
|
{selectedValues.length > 0 ? (
|
||||||
|
<div className="flex justify-between items-center w-full">
|
||||||
|
<div className="flex flex-wrap gap-1 items-center">
|
||||||
|
{selectedValues.slice(0, maxCount).map((value) => {
|
||||||
|
const option = options.find((o) => o.value === value);
|
||||||
|
const IconComponent = option?.icon;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={value}
|
||||||
|
className="flex items-center gap-1 px-2 py-1 bg-primary/10 text-primary border border-primary/20 dark:bg-primary/20 dark:text-primary-foreground dark:border-primary/40 rounded-md">
|
||||||
|
{IconComponent && (
|
||||||
|
<IconComponent className="h-3 w-3 mr-1" />
|
||||||
|
)}
|
||||||
|
<span className="max-w-[150px] truncate text-sm">
|
||||||
|
{option?.label}
|
||||||
|
</span>
|
||||||
|
<XIcon
|
||||||
|
className="h-3.5 w-3.5 cursor-pointer text-primary dark:text-primary-foreground hover:text-red-600 ml-1"
|
||||||
|
onClick={(e) => removeItem(value, e)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{selectedValues.length > maxCount && (
|
||||||
|
<Popover
|
||||||
|
open={isExtraOpen}
|
||||||
|
onOpenChange={setIsExtraOpen}
|
||||||
|
modal={true}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsExtraOpen((prev) => !prev);
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsExtraOpen((prev) => !prev);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-1 px-2 py-1 bg-gray-100 text-gray-700 border border-gray-200 rounded-md cursor-pointer hover:bg-gray-200 transition-colors dark:bg-gray-700 dark:text-gray-200 dark:border-gray-600 dark:hover:bg-gray-600"
|
||||||
|
title={t("msg.selectAll") /* tooltip fallback */}>
|
||||||
|
<span className="text-sm">{`+ ${
|
||||||
|
selectedValues.length - maxCount
|
||||||
|
} more`}</span>
|
||||||
|
</div>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
align="start"
|
||||||
|
className="w-72 p-0"
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="flex items-center justify-between border-b px-3 py-2">
|
||||||
|
<span className="text-xs font-semibold text-slate-700 dark:text-slate-200">
|
||||||
|
{t("ApprovalPage.Selected", "Selected")}
|
||||||
|
</span>
|
||||||
|
<span className="rounded-full bg-primary/15 px-2 py-0.5 text-xs font-medium text-primary dark:bg-primary/30 dark:text-primary-foreground">
|
||||||
|
{selectedValues.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-64 overflow-y-auto py-1">
|
||||||
|
{selectedValues.slice(maxCount).map((val) => {
|
||||||
|
const option = options.find((o) => o.value === val);
|
||||||
|
if (!option) return null;
|
||||||
|
const IconComponent = option.icon;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={val}
|
||||||
|
className="flex items-center justify-between gap-2 px-3 py-1.5 hover:bg-slate-50 dark:hover:bg-slate-800">
|
||||||
|
<span className="flex items-center gap-2 min-w-0">
|
||||||
|
{IconComponent && (
|
||||||
|
<IconComponent className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||||||
|
)}
|
||||||
|
<span className="text-sm truncate">
|
||||||
|
{option.label}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<XIcon
|
||||||
|
className="h-3.5 w-3.5 cursor-pointer text-slate-500 dark:text-slate-400 hover:text-red-600 shrink-0"
|
||||||
|
onClick={(e) => removeItem(val, e)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<ChevronDown className="h-4 mx-2 cursor-pointer text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-between w-full mx-auto">
|
||||||
|
<span className="text-sm text-muted-foreground mx-3">
|
||||||
|
{placeholder}
|
||||||
|
</span>
|
||||||
|
<ChevronDown className="h-4 cursor-pointer text-muted-foreground mx-2" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
className="w-auto p-0 max-w-[400px] dark:bg-gray-800 dark:border-gray-700"
|
||||||
|
align="start"
|
||||||
|
onEscapeKeyDown={() => setIsPopoverOpen(false)}
|
||||||
|
onWheel={(e) => {
|
||||||
|
// Prevent the wheel event from bubbling up to parent elements
|
||||||
|
e.stopPropagation();
|
||||||
|
}}>
|
||||||
|
<Command
|
||||||
|
filter={(value, search, keywords = []) => {
|
||||||
|
const normalizedSearch = normalizeSearchText(search);
|
||||||
|
if (!normalizedSearch) return 1;
|
||||||
|
|
||||||
|
const haystacks = [value, ...keywords]
|
||||||
|
.map((item) => normalizeSearchText(item))
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
return haystacks.some((item) => item.includes(normalizedSearch))
|
||||||
|
? 1
|
||||||
|
: 0;
|
||||||
|
}}>
|
||||||
|
<CommandInput
|
||||||
|
placeholder={t("ApprovalPage.Search")}
|
||||||
|
onKeyDown={handleInputKeyDown}
|
||||||
|
/>
|
||||||
|
<CommandList className="max-h-[200px] overflow-y-auto custom-scrollbar scroll-container">
|
||||||
|
<CommandEmpty>{t("common.noResult")}</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
<CommandItem
|
||||||
|
key="all"
|
||||||
|
onSelect={toggleAll}
|
||||||
|
className="cursor-pointer">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary",
|
||||||
|
selectedValues.length === options.length
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "opacity-50 [&_svg]:invisible",
|
||||||
|
)}>
|
||||||
|
<CheckIcon className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<span>({t("msg.selectAll")})</span>
|
||||||
|
</CommandItem>
|
||||||
|
{options.map((option) => {
|
||||||
|
const isSelected = selectedValues.includes(option.value);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CommandItem
|
||||||
|
key={option.value}
|
||||||
|
value={option.label}
|
||||||
|
keywords={[option.value, option.label]}
|
||||||
|
onSelect={() => toggleOption(option.value)}
|
||||||
|
className="cursor-pointer">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary",
|
||||||
|
isSelected
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "opacity-50 [&_svg]:invisible",
|
||||||
|
)}>
|
||||||
|
<CheckIcon className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
{option.icon && (
|
||||||
|
<option.icon className="mr-2 h-4 w-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
<span>{option.label}</span>
|
||||||
|
</CommandItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</CommandGroup>
|
||||||
|
<CommandSeparator />
|
||||||
|
<CommandGroup>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
{selectedValues.length > 0 && (
|
||||||
|
<>
|
||||||
|
<CommandItem
|
||||||
|
onClick={handleClear}
|
||||||
|
className="flex-1 justify-center cursor-pointer">
|
||||||
|
{t("userIncoming.Clear")}
|
||||||
|
</CommandItem>
|
||||||
|
<Separator
|
||||||
|
orientation="vertical"
|
||||||
|
className="flex min-h-6 h-full"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<CommandItem
|
||||||
|
onSelect={() => setIsPopoverOpen(false)}
|
||||||
|
className="flex-1 justify-center cursor-pointer max-w-full">
|
||||||
|
{t("complaint.close")}
|
||||||
|
</CommandItem>
|
||||||
|
</div>
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
MultiSelect.displayName = "MultiSelect";
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu";
|
||||||
|
import { cva } from "class-variance-authority";
|
||||||
|
import { ChevronDownIcon } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
|
||||||
|
function NavigationMenu({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
viewport = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof NavigationMenuPrimitive.Root> & {
|
||||||
|
viewport?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<NavigationMenuPrimitive.Root
|
||||||
|
data-slot="navigation-menu"
|
||||||
|
data-viewport={viewport}
|
||||||
|
className={cn(
|
||||||
|
"group/navigation-menu relative flex max-w-max flex-1 items-center justify-center",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{viewport && <NavigationMenuViewport />}
|
||||||
|
</NavigationMenuPrimitive.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavigationMenuList({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof NavigationMenuPrimitive.List>) {
|
||||||
|
return (
|
||||||
|
<NavigationMenuPrimitive.List
|
||||||
|
data-slot="navigation-menu-list"
|
||||||
|
className={cn(
|
||||||
|
"group flex flex-1 list-none items-center justify-center gap-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavigationMenuItem({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof NavigationMenuPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<NavigationMenuPrimitive.Item
|
||||||
|
data-slot="navigation-menu-item"
|
||||||
|
className={cn("relative", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const navigationMenuTriggerStyle = cva(
|
||||||
|
"group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=open]:hover:bg-accent data-[state=open]:text-accent-foreground data-[state=open]:focus:bg-accent data-[state=open]:bg-accent/50 ring-ring/10 dark:ring-ring/20 dark:outline-ring/40 outline-ring/50 transition-[color,box-shadow] focus-visible:ring-4 focus-visible:outline-1"
|
||||||
|
);
|
||||||
|
|
||||||
|
function NavigationMenuTrigger({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof NavigationMenuPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<NavigationMenuPrimitive.Trigger
|
||||||
|
data-slot="navigation-menu-trigger"
|
||||||
|
className={cn(navigationMenuTriggerStyle(), "group", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}{" "}
|
||||||
|
<ChevronDownIcon
|
||||||
|
className="relative top-[1px] ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</NavigationMenuPrimitive.Trigger>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavigationMenuContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof NavigationMenuPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<NavigationMenuPrimitive.Content
|
||||||
|
data-slot="navigation-menu-content"
|
||||||
|
className={cn(
|
||||||
|
"data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 top-0 left-0 w-full p-2 pr-2.5 md:absolute md:w-auto",
|
||||||
|
"group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded-md group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:duration-200 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavigationMenuViewport({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof NavigationMenuPrimitive.Viewport>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"absolute top-full left-0 isolate z-50 flex justify-center"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<NavigationMenuPrimitive.Viewport
|
||||||
|
data-slot="navigation-menu-viewport"
|
||||||
|
className={cn(
|
||||||
|
"origin-top-center bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border shadow md:w-[var(--radix-navigation-menu-viewport-width)]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavigationMenuLink({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof NavigationMenuPrimitive.Link>) {
|
||||||
|
return (
|
||||||
|
<NavigationMenuPrimitive.Link
|
||||||
|
data-slot="navigation-menu-link"
|
||||||
|
className={cn(
|
||||||
|
"data-[active=true]:focus:bg-accent data-[active=true]:hover:bg-accent data-[active=true]:bg-accent/50 data-[active=true]:text-accent-foreground hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground ring-ring/10 dark:ring-ring/20 dark:outline-ring/40 outline-ring/50 [&_svg:not([class*='text-'])]:text-muted-foreground flex flex-col gap-1 rounded-sm p-2 text-sm transition-[color,box-shadow] focus-visible:ring-4 focus-visible:outline-1 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavigationMenuIndicator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof NavigationMenuPrimitive.Indicator>) {
|
||||||
|
return (
|
||||||
|
<NavigationMenuPrimitive.Indicator
|
||||||
|
data-slot="navigation-menu-indicator"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className="bg-border relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm shadow-md" />
|
||||||
|
</NavigationMenuPrimitive.Indicator>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
NavigationMenu,
|
||||||
|
NavigationMenuList,
|
||||||
|
NavigationMenuItem,
|
||||||
|
NavigationMenuContent,
|
||||||
|
NavigationMenuTrigger,
|
||||||
|
NavigationMenuLink,
|
||||||
|
NavigationMenuIndicator,
|
||||||
|
NavigationMenuViewport,
|
||||||
|
navigationMenuTriggerStyle,
|
||||||
|
};
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import {
|
||||||
|
ChevronLeftIcon,
|
||||||
|
ChevronRightIcon,
|
||||||
|
MoreHorizontalIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
import { Button, buttonVariants } from "@/shared/common/ui/button";
|
||||||
|
|
||||||
|
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
|
||||||
|
return (
|
||||||
|
<nav
|
||||||
|
role="navigation"
|
||||||
|
aria-label="pagination"
|
||||||
|
data-slot="pagination"
|
||||||
|
className={cn("mx-auto flex w-full justify-center", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaginationContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"ul">) {
|
||||||
|
return (
|
||||||
|
<ul
|
||||||
|
data-slot="pagination-content"
|
||||||
|
className={cn("flex flex-row items-center gap-1", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
|
||||||
|
return <li data-slot="pagination-item" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
type PaginationLinkProps = {
|
||||||
|
isActive?: boolean;
|
||||||
|
} & Pick<React.ComponentProps<typeof Button>, "size"> &
|
||||||
|
React.ComponentProps<"a">;
|
||||||
|
|
||||||
|
function PaginationLink({
|
||||||
|
className,
|
||||||
|
isActive,
|
||||||
|
size = "icon",
|
||||||
|
...props
|
||||||
|
}: PaginationLinkProps) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
aria-current={isActive ? "page" : undefined}
|
||||||
|
data-slot="pagination-link"
|
||||||
|
data-active={isActive}
|
||||||
|
className={cn(
|
||||||
|
buttonVariants({
|
||||||
|
variant: isActive ? "outline" : "ghost",
|
||||||
|
size,
|
||||||
|
}),
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaginationPrevious({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PaginationLink>) {
|
||||||
|
return (
|
||||||
|
<PaginationLink
|
||||||
|
aria-label="Go to previous page"
|
||||||
|
size="default"
|
||||||
|
className={cn("gap-1 px-2.5 sm:pl-2.5", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronLeftIcon />
|
||||||
|
<span className="hidden sm:block">Previous</span>
|
||||||
|
</PaginationLink>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaginationNext({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PaginationLink>) {
|
||||||
|
return (
|
||||||
|
<PaginationLink
|
||||||
|
aria-label="Go to next page"
|
||||||
|
size="default"
|
||||||
|
className={cn("gap-1 px-2.5 sm:pr-2.5", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="hidden sm:block">Next</span>
|
||||||
|
<ChevronRightIcon />
|
||||||
|
</PaginationLink>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaginationEllipsis({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
data-slot="pagination-ellipsis"
|
||||||
|
className={cn("flex size-9 items-center justify-center", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<MoreHorizontalIcon className="size-4" />
|
||||||
|
<span className="sr-only">More pages</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Pagination,
|
||||||
|
PaginationContent,
|
||||||
|
PaginationLink,
|
||||||
|
PaginationItem,
|
||||||
|
PaginationPrevious,
|
||||||
|
PaginationNext,
|
||||||
|
PaginationEllipsis,
|
||||||
|
};
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
||||||
|
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
|
||||||
|
function Popover({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||||
|
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||||
|
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverContent({
|
||||||
|
className,
|
||||||
|
align = "center",
|
||||||
|
sideOffset = 4,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<PopoverPrimitive.Portal>
|
||||||
|
<PopoverPrimitive.Content
|
||||||
|
data-slot="popover-content"
|
||||||
|
align={align}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 rounded-md border p-4 shadow-md outline-hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</PopoverPrimitive.Portal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverAnchor({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||||
|
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import * as ProgressPrimitive from "@radix-ui/react-progress";
|
||||||
|
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
|
||||||
|
function Progress({
|
||||||
|
className,
|
||||||
|
value,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<ProgressPrimitive.Root
|
||||||
|
data-slot="progress"
|
||||||
|
className={cn(
|
||||||
|
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ProgressPrimitive.Indicator
|
||||||
|
data-slot="progress-indicator"
|
||||||
|
className="bg-primary h-full w-full flex-1 transition-all"
|
||||||
|
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||||
|
/>
|
||||||
|
</ProgressPrimitive.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Progress };
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
|
||||||
|
import { CircleIcon } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
|
||||||
|
function RadioGroup({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<RadioGroupPrimitive.Root
|
||||||
|
data-slot="radio-group"
|
||||||
|
className={cn("grid gap-3", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RadioGroupItem({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<RadioGroupPrimitive.Item
|
||||||
|
data-slot="radio-group-item"
|
||||||
|
className={cn(
|
||||||
|
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<RadioGroupPrimitive.Indicator
|
||||||
|
data-slot="radio-group-indicator"
|
||||||
|
className="relative flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
|
||||||
|
</RadioGroupPrimitive.Indicator>
|
||||||
|
</RadioGroupPrimitive.Item>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { RadioGroup, RadioGroupItem };
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { GripVerticalIcon } from "lucide-react";
|
||||||
|
import * as ResizablePrimitive from "react-resizable-panels";
|
||||||
|
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
|
||||||
|
function ResizablePanelGroup({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) {
|
||||||
|
return (
|
||||||
|
<ResizablePrimitive.PanelGroup
|
||||||
|
data-slot="resizable-panel-group"
|
||||||
|
className={cn(
|
||||||
|
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResizablePanel({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ResizablePrimitive.Panel>) {
|
||||||
|
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResizableHandle({
|
||||||
|
withHandle,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
|
||||||
|
withHandle?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ResizablePrimitive.PanelResizeHandle
|
||||||
|
data-slot="resizable-handle"
|
||||||
|
className={cn(
|
||||||
|
"bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{withHandle && (
|
||||||
|
<div className="bg-border z-10 flex h-4 w-3 items-center justify-center rounded-xs border">
|
||||||
|
<GripVerticalIcon className="size-2.5" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ResizablePrimitive.PanelResizeHandle>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ResizablePanelGroup, ResizablePanel, ResizableHandle };
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
|
||||||
|
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
|
||||||
|
function ScrollArea({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<ScrollAreaPrimitive.Root
|
||||||
|
data-slot="scroll-area"
|
||||||
|
className={cn("relative", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.Viewport
|
||||||
|
data-slot="scroll-area-viewport"
|
||||||
|
className="ring-ring/10 dark:ring-ring/20 dark:outline-ring/40 outline-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] focus-visible:ring-4 focus-visible:outline-1"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ScrollAreaPrimitive.Viewport>
|
||||||
|
<ScrollBar />
|
||||||
|
<ScrollAreaPrimitive.Corner />
|
||||||
|
</ScrollAreaPrimitive.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScrollBar({
|
||||||
|
className,
|
||||||
|
orientation = "vertical",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||||
|
return (
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||||
|
data-slot="scroll-area-scrollbar"
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"flex touch-none p-px transition-colors select-none",
|
||||||
|
orientation === "vertical" &&
|
||||||
|
"h-full w-2.5 border-l border-l-transparent",
|
||||||
|
orientation === "horizontal" &&
|
||||||
|
"h-2.5 flex-col border-t border-t-transparent",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||||
|
data-slot="scroll-area-thumb"
|
||||||
|
className="bg-border relative flex-1 rounded-full"
|
||||||
|
/>
|
||||||
|
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ScrollArea, ScrollBar };
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||||
|
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
|
||||||
|
function Select({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||||
|
return <SelectPrimitive.Root data-slot="select" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||||
|
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectValue({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||||
|
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectTrigger({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
data-slot="select-trigger"
|
||||||
|
className={cn(
|
||||||
|
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive flex h-9 w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronDownIcon className="size-4 opacity-50" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
position = "popper",
|
||||||
|
onScroll,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Content> & {
|
||||||
|
onScroll?: (e: React.UIEvent<HTMLDivElement>) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
data-slot="select-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border shadow-md",
|
||||||
|
position === "popper" &&
|
||||||
|
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
position={position}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectPrimitive.Viewport
|
||||||
|
className={cn(
|
||||||
|
"p-1",
|
||||||
|
position === "popper" &&
|
||||||
|
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
|
||||||
|
)}
|
||||||
|
onScroll={onScroll}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectLabel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Label
|
||||||
|
data-slot="select-label"
|
||||||
|
className={cn("px-2 py-1.5 text-sm font-medium", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
data-slot="select-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Separator
|
||||||
|
data-slot="select-separator"
|
||||||
|
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollUpButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollUpButton
|
||||||
|
data-slot="select-scroll-up-button"
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronUpIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ScrollUpButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollDownButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollDownButton
|
||||||
|
data-slot="select-scroll-down-button"
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronDownIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ScrollDownButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
};
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as SeparatorPrimitive from "@radix-ui/react-separator";
|
||||||
|
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
|
||||||
|
function Separator({
|
||||||
|
className,
|
||||||
|
orientation = "horizontal",
|
||||||
|
decorative = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<SeparatorPrimitive.Root
|
||||||
|
data-slot="separator-root"
|
||||||
|
decorative={decorative}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Separator };
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import * as SheetPrimitive from "@radix-ui/react-dialog";
|
||||||
|
import { XIcon } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
|
||||||
|
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||||
|
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||||
|
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetClose({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||||
|
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||||
|
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Overlay
|
||||||
|
data-slot="sheet-overlay"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
side = "right",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||||
|
side?: "top" | "right" | "bottom" | "left";
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SheetPortal>
|
||||||
|
<SheetOverlay />
|
||||||
|
<SheetPrimitive.Content
|
||||||
|
data-slot="sheet-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||||
|
side === "right" &&
|
||||||
|
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
|
||||||
|
side === "left" &&
|
||||||
|
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
|
||||||
|
side === "top" &&
|
||||||
|
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
|
||||||
|
side === "bottom" &&
|
||||||
|
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
|
||||||
|
<XIcon className="size-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</SheetPrimitive.Close>
|
||||||
|
</SheetPrimitive.Content>
|
||||||
|
</SheetPortal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sheet-header"
|
||||||
|
className={cn("flex flex-col gap-1.5 p-4", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sheet-footer"
|
||||||
|
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Title
|
||||||
|
data-slot="sheet-title"
|
||||||
|
className={cn("text-foreground font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Description
|
||||||
|
data-slot="sheet-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Sheet,
|
||||||
|
SheetTrigger,
|
||||||
|
SheetClose,
|
||||||
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetFooter,
|
||||||
|
SheetTitle,
|
||||||
|
SheetDescription,
|
||||||
|
};
|
||||||
@@ -0,0 +1,727 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
|
import { VariantProps, cva } from "class-variance-authority";
|
||||||
|
import { PanelLeftIcon } from "lucide-react";
|
||||||
|
|
||||||
|
import { useIsMobile } from "@/record-management/hooks/use-mobile";
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
import { Button } from "@/shared/common/ui/button";
|
||||||
|
import { Input } from "@/shared/common/ui/input";
|
||||||
|
import { Separator } from "@/shared/common/ui/separator";
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetDescription,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
} from "@/shared/common/ui/sheet";
|
||||||
|
import { Skeleton } from "@/shared/common/ui/skeleton";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/shared/common/ui/tooltip";
|
||||||
|
|
||||||
|
const SIDEBAR_COOKIE_NAME = "sidebar_state";
|
||||||
|
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
|
||||||
|
const SIDEBAR_WIDTH = "16rem";
|
||||||
|
const SIDEBAR_WIDTH_MOBILE = "80vw"; // Changed from fixed size to viewport-relative
|
||||||
|
const SIDEBAR_WIDTH_ICON = "3rem";
|
||||||
|
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
|
||||||
|
|
||||||
|
type SidebarContext = {
|
||||||
|
state: "expanded" | "collapsed";
|
||||||
|
open: boolean;
|
||||||
|
setOpen: (open: boolean) => void;
|
||||||
|
openMobile: boolean;
|
||||||
|
setOpenMobile: (open: boolean) => void;
|
||||||
|
isMobile: boolean;
|
||||||
|
toggleSidebar: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SidebarContext = React.createContext<SidebarContext | null>(null);
|
||||||
|
|
||||||
|
function useSidebar() {
|
||||||
|
const context = React.useContext(SidebarContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useSidebar must be used within a SidebarProvider.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarProvider({
|
||||||
|
defaultOpen = true,
|
||||||
|
open: openProp,
|
||||||
|
onOpenChange: setOpenProp,
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
defaultOpen?: boolean;
|
||||||
|
open?: boolean;
|
||||||
|
onOpenChange?: (open: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
const [openMobile, setOpenMobile] = React.useState(false);
|
||||||
|
|
||||||
|
// This is the internal state of the sidebar.
|
||||||
|
// We use openProp and setOpenProp for control from outside the component.
|
||||||
|
const [_open, _setOpen] = React.useState(defaultOpen);
|
||||||
|
const open = openProp ?? _open;
|
||||||
|
const setOpen = React.useCallback(
|
||||||
|
(value: boolean | ((value: boolean) => boolean)) => {
|
||||||
|
const openState = typeof value === "function" ? value(open) : value;
|
||||||
|
if (setOpenProp) {
|
||||||
|
setOpenProp(openState);
|
||||||
|
} else {
|
||||||
|
_setOpen(openState);
|
||||||
|
}
|
||||||
|
|
||||||
|
// This sets the cookie to keep the sidebar state.
|
||||||
|
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
|
||||||
|
},
|
||||||
|
[setOpenProp, open]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Helper to toggle the sidebar.
|
||||||
|
const toggleSidebar = React.useCallback(() => {
|
||||||
|
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
|
||||||
|
}, [isMobile, setOpen, setOpenMobile]);
|
||||||
|
|
||||||
|
// Adds a keyboard shortcut to toggle the sidebar.
|
||||||
|
React.useEffect(() => {
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (
|
||||||
|
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
|
||||||
|
(event.metaKey || event.ctrlKey)
|
||||||
|
) {
|
||||||
|
event.preventDefault();
|
||||||
|
toggleSidebar();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [toggleSidebar]);
|
||||||
|
|
||||||
|
// We add a state so that we can do data-state="expanded" or "collapsed".
|
||||||
|
// This makes it easier to style the sidebar with Tailwind classes.
|
||||||
|
const state = open ? "expanded" : "collapsed";
|
||||||
|
|
||||||
|
const contextValue = React.useMemo<SidebarContext>(
|
||||||
|
() => ({
|
||||||
|
state,
|
||||||
|
open,
|
||||||
|
setOpen,
|
||||||
|
isMobile,
|
||||||
|
openMobile,
|
||||||
|
setOpenMobile,
|
||||||
|
toggleSidebar,
|
||||||
|
}),
|
||||||
|
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarContext.Provider value={contextValue}>
|
||||||
|
<TooltipProvider delayDuration={0}>
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-wrapper"
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--sidebar-width": SIDEBAR_WIDTH,
|
||||||
|
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
|
||||||
|
...style,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
|
</SidebarContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Sidebar({
|
||||||
|
side = "left",
|
||||||
|
variant = "sidebar",
|
||||||
|
collapsible = "offcanvas",
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
side?: "left" | "right";
|
||||||
|
variant?: "sidebar" | "floating" | "inset";
|
||||||
|
collapsible?: "offcanvas" | "icon" | "none";
|
||||||
|
}) {
|
||||||
|
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
|
||||||
|
|
||||||
|
if (collapsible === "none") {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sidebar"
|
||||||
|
className={cn(
|
||||||
|
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
return (
|
||||||
|
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
|
||||||
|
<SheetContent
|
||||||
|
data-sidebar="sidebar"
|
||||||
|
data-slot="sidebar"
|
||||||
|
data-mobile="true"
|
||||||
|
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:top-4 [&>button]:right-4 max-w-full"
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
side={side}
|
||||||
|
>
|
||||||
|
<SheetHeader className="sr-only">
|
||||||
|
<SheetTitle>Sidebar</SheetTitle>
|
||||||
|
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
|
||||||
|
</SheetHeader>
|
||||||
|
<div className="flex h-full w-full flex-col">{children}</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="group peer text-sidebar-foreground hidden md:block"
|
||||||
|
data-state={state}
|
||||||
|
data-collapsible={state === "collapsed" ? collapsible : ""}
|
||||||
|
data-variant={variant}
|
||||||
|
data-side={side}
|
||||||
|
data-slot="sidebar"
|
||||||
|
>
|
||||||
|
{/* This is what handles the sidebar gap on desktop */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
|
||||||
|
"group-data-[collapsible=offcanvas]:w-0",
|
||||||
|
"group-data-[side=right]:rotate-180",
|
||||||
|
variant === "floating" || variant === "inset"
|
||||||
|
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
|
||||||
|
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
|
||||||
|
side === "left"
|
||||||
|
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
|
||||||
|
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
|
||||||
|
// Adjust the padding for floating and inset variants.
|
||||||
|
variant === "floating" || variant === "inset"
|
||||||
|
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
|
||||||
|
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
data-sidebar="sidebar"
|
||||||
|
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarTrigger({
|
||||||
|
className,
|
||||||
|
onClick,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Button>) {
|
||||||
|
const { toggleSidebar } = useSidebar();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
data-sidebar="trigger"
|
||||||
|
data-slot="sidebar-trigger"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className={cn("h-7 w-7", className)}
|
||||||
|
onClick={(event) => {
|
||||||
|
onClick?.(event);
|
||||||
|
toggleSidebar();
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<PanelLeftIcon />
|
||||||
|
<span className="sr-only">Toggle Sidebar</span>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
|
||||||
|
const { toggleSidebar } = useSidebar();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
data-sidebar="rail"
|
||||||
|
data-slot="sidebar-rail"
|
||||||
|
aria-label="Toggle Sidebar"
|
||||||
|
tabIndex={-1}
|
||||||
|
onClick={toggleSidebar}
|
||||||
|
title="Toggle Sidebar"
|
||||||
|
className={cn(
|
||||||
|
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex",
|
||||||
|
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
|
||||||
|
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
|
||||||
|
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
|
||||||
|
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
|
||||||
|
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
|
||||||
|
return (
|
||||||
|
<main
|
||||||
|
data-slot="sidebar-inset"
|
||||||
|
className={cn(
|
||||||
|
"bg-background relative flex w-full flex-1 flex-col",
|
||||||
|
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarInput({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Input>) {
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
data-slot="sidebar-input"
|
||||||
|
data-sidebar="input"
|
||||||
|
className={cn("bg-background h-8 w-full shadow-none", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-header"
|
||||||
|
data-sidebar="header"
|
||||||
|
className={cn("flex flex-col gap-2 p-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-footer"
|
||||||
|
data-sidebar="footer"
|
||||||
|
className={cn("flex flex-col gap-2 p-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Separator>) {
|
||||||
|
return (
|
||||||
|
<Separator
|
||||||
|
data-slot="sidebar-separator"
|
||||||
|
data-sidebar="separator"
|
||||||
|
className={cn("bg-sidebar-border mx-2 w-auto", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-content"
|
||||||
|
data-sidebar="content"
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-group"
|
||||||
|
data-sidebar="group"
|
||||||
|
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarGroupLabel({
|
||||||
|
className,
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
|
||||||
|
const Comp = asChild ? Slot : "div";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="sidebar-group-label"
|
||||||
|
data-sidebar="group-label"
|
||||||
|
className={cn(
|
||||||
|
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||||
|
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarGroupAction({
|
||||||
|
className,
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
|
||||||
|
const Comp = asChild ? Slot : "button";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="sidebar-group-action"
|
||||||
|
data-sidebar="group-action"
|
||||||
|
className={cn(
|
||||||
|
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||||
|
// Increases the hit area of the button on mobile.
|
||||||
|
"after:absolute after:-inset-2 md:after:hidden",
|
||||||
|
"group-data-[collapsible=icon]:hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarGroupContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-group-content"
|
||||||
|
data-sidebar="group-content"
|
||||||
|
className={cn("w-full text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
|
||||||
|
return (
|
||||||
|
<ul
|
||||||
|
data-slot="sidebar-menu"
|
||||||
|
data-sidebar="menu"
|
||||||
|
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
data-slot="sidebar-menu-item"
|
||||||
|
data-sidebar="menu-item"
|
||||||
|
className={cn("group/menu-item relative", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sidebarMenuButtonVariants = cva(
|
||||||
|
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||||
|
outline:
|
||||||
|
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-8 text-sm",
|
||||||
|
sm: "h-7 text-xs",
|
||||||
|
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
function SidebarMenuButton({
|
||||||
|
asChild = false,
|
||||||
|
isActive = false,
|
||||||
|
variant = "default",
|
||||||
|
size = "default",
|
||||||
|
tooltip,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"button"> & {
|
||||||
|
asChild?: boolean;
|
||||||
|
isActive?: boolean;
|
||||||
|
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
|
||||||
|
} & VariantProps<typeof sidebarMenuButtonVariants>) {
|
||||||
|
const { isMobile } = useSidebar();
|
||||||
|
const Comp = asChild ? Slot : "button";
|
||||||
|
|
||||||
|
const button = (
|
||||||
|
<Comp
|
||||||
|
data-slot="sidebar-menu-button"
|
||||||
|
data-sidebar="menu-button"
|
||||||
|
data-size={size}
|
||||||
|
data-active={isActive}
|
||||||
|
className={cn(
|
||||||
|
sidebarMenuButtonVariants({ variant, size }),
|
||||||
|
isActive && "text-accent-foreground [&_svg]:text-accent-foreground",
|
||||||
|
isMobile && "min-h-[44px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// On mobile, we don't show tooltips
|
||||||
|
if (isMobile || !tooltip) {
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Desktop experience with tooltips
|
||||||
|
if (typeof tooltip === "string") {
|
||||||
|
tooltip = {
|
||||||
|
children: tooltip,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
side="right"
|
||||||
|
align="center"
|
||||||
|
{...(typeof tooltip === "object" ? tooltip : {})}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarMenuAction({
|
||||||
|
className,
|
||||||
|
asChild = false,
|
||||||
|
showOnHover = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"button"> & {
|
||||||
|
asChild?: boolean;
|
||||||
|
showOnHover?: boolean;
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot : "button";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="sidebar-menu-action"
|
||||||
|
data-sidebar="menu-action"
|
||||||
|
className={cn(
|
||||||
|
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||||
|
// Increases the hit area of the button on mobile.
|
||||||
|
"after:absolute after:-inset-2 md:after:hidden",
|
||||||
|
"peer-data-[size=sm]/menu-button:top-1",
|
||||||
|
"peer-data-[size=default]/menu-button:top-1.5",
|
||||||
|
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||||
|
"group-data-[collapsible=icon]:hidden",
|
||||||
|
showOnHover &&
|
||||||
|
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarMenuBadge({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-menu-badge"
|
||||||
|
data-sidebar="menu-badge"
|
||||||
|
className={cn(
|
||||||
|
"text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none",
|
||||||
|
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
|
||||||
|
"peer-data-[size=sm]/menu-button:top-1",
|
||||||
|
"peer-data-[size=default]/menu-button:top-1.5",
|
||||||
|
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||||
|
"group-data-[collapsible=icon]:hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarMenuSkeleton({
|
||||||
|
className,
|
||||||
|
showIcon = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
showIcon?: boolean;
|
||||||
|
}) {
|
||||||
|
// Random width between 50 to 90%.
|
||||||
|
const width = React.useMemo(() => {
|
||||||
|
return `${Math.floor(Math.random() * 40) + 50}%`;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-menu-skeleton"
|
||||||
|
data-sidebar="menu-skeleton"
|
||||||
|
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{showIcon && (
|
||||||
|
<Skeleton
|
||||||
|
className="size-4 rounded-md"
|
||||||
|
data-sidebar="menu-skeleton-icon"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Skeleton
|
||||||
|
className="h-4 max-w-(--skeleton-width) flex-1"
|
||||||
|
data-sidebar="menu-skeleton-text"
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--skeleton-width": width,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
|
||||||
|
return (
|
||||||
|
<ul
|
||||||
|
data-slot="sidebar-menu-sub"
|
||||||
|
data-sidebar="menu-sub"
|
||||||
|
className={cn(
|
||||||
|
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
|
||||||
|
"group-data-[collapsible=icon]:hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarMenuSubItem({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"li">) {
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
data-slot="sidebar-menu-sub-item"
|
||||||
|
data-sidebar="menu-sub-item"
|
||||||
|
className={cn("group/menu-sub-item relative", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarMenuSubButton({
|
||||||
|
asChild = false,
|
||||||
|
size = "md",
|
||||||
|
isActive = false,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"a"> & {
|
||||||
|
asChild?: boolean;
|
||||||
|
size?: "sm" | "md";
|
||||||
|
isActive?: boolean;
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot : "a";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="sidebar-menu-sub-button"
|
||||||
|
data-sidebar="menu-sub-button"
|
||||||
|
data-size={size}
|
||||||
|
data-active={isActive}
|
||||||
|
className={cn(
|
||||||
|
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||||
|
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
|
||||||
|
size === "sm" && "text-xs",
|
||||||
|
size === "md" && "text-sm",
|
||||||
|
"group-data-[collapsible=icon]:hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Sidebar,
|
||||||
|
SidebarContent,
|
||||||
|
SidebarFooter,
|
||||||
|
SidebarGroup,
|
||||||
|
SidebarGroupAction,
|
||||||
|
SidebarGroupContent,
|
||||||
|
SidebarGroupLabel,
|
||||||
|
SidebarHeader,
|
||||||
|
SidebarInput,
|
||||||
|
SidebarInset,
|
||||||
|
SidebarMenu,
|
||||||
|
SidebarMenuAction,
|
||||||
|
SidebarMenuBadge,
|
||||||
|
SidebarMenuButton,
|
||||||
|
SidebarMenuItem,
|
||||||
|
SidebarMenuSkeleton,
|
||||||
|
SidebarMenuSub,
|
||||||
|
SidebarMenuSubButton,
|
||||||
|
SidebarMenuSubItem,
|
||||||
|
SidebarProvider,
|
||||||
|
SidebarRail,
|
||||||
|
SidebarSeparator,
|
||||||
|
SidebarTrigger,
|
||||||
|
useSidebar,
|
||||||
|
};
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { Button } from "@/shared/common/ui/button";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverTrigger,
|
||||||
|
PopoverContent,
|
||||||
|
} from "@/shared/common/ui/popover";
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandInput,
|
||||||
|
CommandList,
|
||||||
|
CommandItem,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
} from "@/shared/common/ui/command";
|
||||||
|
import { CheckIcon, ChevronDown } from "lucide-react";
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
|
||||||
|
const normalizeSearchText = (value: string) =>
|
||||||
|
value.normalize("NFKC").toLocaleLowerCase().replace(/\s+/g, " ").trim();
|
||||||
|
|
||||||
|
interface SingleSelectProps {
|
||||||
|
options: { label: string; value: string }[];
|
||||||
|
value?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
onValueChange: (value: string) => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SingleSelect = React.forwardRef<
|
||||||
|
HTMLButtonElement,
|
||||||
|
SingleSelectProps
|
||||||
|
>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
options = [],
|
||||||
|
value,
|
||||||
|
placeholder = "Select an option",
|
||||||
|
onValueChange,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
) => {
|
||||||
|
const [isOpen, setIsOpen] = React.useState(false);
|
||||||
|
const [searchQuery, setSearchQuery] = React.useState("");
|
||||||
|
|
||||||
|
const normalizedQuery = normalizeSearchText(searchQuery);
|
||||||
|
const filteredOptions = options.filter((opt) =>
|
||||||
|
normalizeSearchText(opt.label).includes(normalizedQuery),
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedOption = options.find((o) => o.value === value);
|
||||||
|
|
||||||
|
const handleSelect = (val: string) => {
|
||||||
|
onValueChange(val);
|
||||||
|
setIsOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
className={cn(
|
||||||
|
"flex h-10 w-full min-w-0 max-w-full shrink items-center justify-between overflow-hidden rounded-md border border-gray-300 bg-white text-gray-900",
|
||||||
|
"px-3 py-2 text-sm font-normal shadow-sm transition-all duration-150",
|
||||||
|
"hover:bg-gray-50 hover:border-gray-400 focus:ring-2 focus:ring-primary-200 focus:border-primary-500",
|
||||||
|
"dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200",
|
||||||
|
"dark:hover:bg-gray-600 dark:hover:border-gray-500",
|
||||||
|
isOpen &&
|
||||||
|
"border-primary-500 ring-2 ring-primary-100 dark:ring-primary-900/40",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"block min-w-0 flex-1 truncate text-left",
|
||||||
|
selectedOption
|
||||||
|
? "font-medium text-gray-900 dark:text-gray-100"
|
||||||
|
: "font-normal text-gray-500 dark:text-gray-400",
|
||||||
|
)}
|
||||||
|
title={selectedOption ? selectedOption.label : undefined}
|
||||||
|
>
|
||||||
|
{selectedOption ? selectedOption.label : placeholder}
|
||||||
|
</span>
|
||||||
|
<ChevronDown
|
||||||
|
className={cn(
|
||||||
|
"ml-2 h-4 w-4 shrink-0 text-gray-500 transition-transform duration-200",
|
||||||
|
"dark:text-gray-400",
|
||||||
|
isOpen && "rotate-180",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
|
||||||
|
<PopoverContent
|
||||||
|
className="w-[var(--radix-popover-trigger-width)] max-w-full p-0 border border-gray-200 rounded-lg shadow-lg bg-white dark:border-gray-600 dark:bg-gray-800"
|
||||||
|
align="start"
|
||||||
|
>
|
||||||
|
<Command shouldFilter={false}>
|
||||||
|
<CommandInput
|
||||||
|
placeholder="Search..."
|
||||||
|
value={searchQuery}
|
||||||
|
onValueChange={setSearchQuery}
|
||||||
|
className="text-sm dark:text-gray-200 dark:placeholder:text-gray-500"
|
||||||
|
/>
|
||||||
|
<CommandList
|
||||||
|
className="max-h-60 overflow-y-auto"
|
||||||
|
onWheel={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<CommandEmpty className="py-3 text-gray-500 text-sm text-center dark:text-gray-400">
|
||||||
|
No results found.
|
||||||
|
</CommandEmpty>
|
||||||
|
|
||||||
|
<CommandGroup>
|
||||||
|
{filteredOptions.map((opt) => (
|
||||||
|
<CommandItem
|
||||||
|
key={opt.value}
|
||||||
|
value={opt.label}
|
||||||
|
onSelect={() => handleSelect(opt.value)}
|
||||||
|
className={cn(
|
||||||
|
"cursor-pointer flex items-center justify-between px-3 py-2 rounded-md text-sm transition-colors duration-100",
|
||||||
|
|
||||||
|
"hover:bg-primary-50 hover:text-primary-700",
|
||||||
|
"focus:bg-primary-50",
|
||||||
|
|
||||||
|
"dark:hover:bg-primary-700 dark:focus:bg-primary-700 dark:text-gray-200",
|
||||||
|
|
||||||
|
// Remove cmdk's default active styling
|
||||||
|
"data-[selected=true]:text-inherit",
|
||||||
|
"data-[selected=true]:bg-transparent",
|
||||||
|
|
||||||
|
value === opt.value &&
|
||||||
|
"bg-primary-100 text-primary-700 font-medium dark:bg-primary-900/50 dark:text-primary-300",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="min-w-0 flex-1 truncate">{opt.label}</span>
|
||||||
|
{value === opt.value && (
|
||||||
|
<CheckIcon className="h-4 w-4 shrink-0 text-primary-600" />
|
||||||
|
)}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
SingleSelect.displayName = "SingleSelect";
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
|
||||||
|
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="skeleton"
|
||||||
|
className={cn("bg-primary/10 animate-pulse rounded-md", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Skeleton };
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { useTheme } from "next-themes"
|
||||||
|
import { Toaster as Sonner, ToasterProps } from "sonner"
|
||||||
|
|
||||||
|
const Toaster = ({ ...props }: ToasterProps) => {
|
||||||
|
const { theme = "system" } = useTheme()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sonner
|
||||||
|
theme={theme as ToasterProps["theme"]}
|
||||||
|
className="toaster group"
|
||||||
|
toastOptions={{
|
||||||
|
classNames: {
|
||||||
|
toast:
|
||||||
|
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
|
||||||
|
description: "group-[.toast]:text-muted-foreground",
|
||||||
|
actionButton:
|
||||||
|
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground font-medium",
|
||||||
|
cancelButton:
|
||||||
|
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground font-medium",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Toaster }
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
import { VariantProps, cva } from "class-variance-authority";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import Loader from "@/record-management/common/loader";
|
||||||
|
|
||||||
|
const spinnerVariants = cva("flex-col items-center justify-center", {
|
||||||
|
variants: {
|
||||||
|
show: {
|
||||||
|
true: "flex",
|
||||||
|
false: "hidden",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
show: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const loaderVariants = cva("animate-spin text-primary", {
|
||||||
|
variants: {
|
||||||
|
size: {
|
||||||
|
small: "size-6",
|
||||||
|
medium: "size-8",
|
||||||
|
large: "size-12",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
size: "medium",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
interface SpinnerContentProps
|
||||||
|
extends VariantProps<typeof spinnerVariants>,
|
||||||
|
VariantProps<typeof loaderVariants> {
|
||||||
|
className?: string;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Spinner({
|
||||||
|
show,
|
||||||
|
children,
|
||||||
|
}: SpinnerContentProps) {
|
||||||
|
return (
|
||||||
|
<span className={spinnerVariants({ show })}>
|
||||||
|
<Loader />
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import React, { useState, useCallback } from "react";
|
||||||
|
import { Star } from "lucide-react";
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
|
||||||
|
export interface StarRatingProps {
|
||||||
|
value?: number;
|
||||||
|
onChange?: (rating: number) => void;
|
||||||
|
maxRating?: number;
|
||||||
|
size?: "sm" | "md" | "lg";
|
||||||
|
disabled?: boolean;
|
||||||
|
showLabels?: boolean;
|
||||||
|
className?: string;
|
||||||
|
labels?: string[];
|
||||||
|
language?: "am" | "en";
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultLabelsEn = ["Very Poor", "Poor", "Normal", "Good", "Excellent"];
|
||||||
|
const defaultLabelsAm = ["በጣም መጥፎ", "መጥፎ", "መካከለኛ", "ጥሩ", "በጣም ጥሩ"];
|
||||||
|
|
||||||
|
export const StarRating: React.FC<StarRatingProps> = ({
|
||||||
|
value = 0,
|
||||||
|
onChange,
|
||||||
|
maxRating = 5,
|
||||||
|
size = "md",
|
||||||
|
disabled = false,
|
||||||
|
showLabels = true,
|
||||||
|
className,
|
||||||
|
labels,
|
||||||
|
language = "en",
|
||||||
|
}) => {
|
||||||
|
// Use provided labels or default based on language
|
||||||
|
const ratingLabels =
|
||||||
|
labels || (language === "am" ? defaultLabelsAm : defaultLabelsEn);
|
||||||
|
const [hoverRating, setHoverRating] = useState(0);
|
||||||
|
const [isHovering, setIsHovering] = useState(false);
|
||||||
|
|
||||||
|
const sizeClasses = {
|
||||||
|
sm: "w-4 h-4",
|
||||||
|
md: "w-6 h-6",
|
||||||
|
lg: "w-8 h-8",
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStarClick = useCallback(
|
||||||
|
(rating: number) => {
|
||||||
|
if (!disabled && onChange) {
|
||||||
|
// Use requestAnimationFrame to prevent vibration
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
onChange(rating);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[disabled, onChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleStarHover = useCallback(
|
||||||
|
(rating: number) => {
|
||||||
|
if (!disabled) {
|
||||||
|
if (!isHovering || rating !== hoverRating) {
|
||||||
|
setHoverRating(rating);
|
||||||
|
setIsHovering(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[disabled, isHovering, hoverRating]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleMouseLeave = useCallback(() => {
|
||||||
|
if (isHovering) {
|
||||||
|
setIsHovering(false);
|
||||||
|
setHoverRating(0);
|
||||||
|
}
|
||||||
|
}, [isHovering]);
|
||||||
|
|
||||||
|
const displayRating = isHovering ? hoverRating : value;
|
||||||
|
const currentLabel = ratingLabels[displayRating - 1] || "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn("flex flex-col items-center gap-4", className)}
|
||||||
|
style={{
|
||||||
|
backfaceVisibility: "hidden",
|
||||||
|
transform: "translateZ(0)",
|
||||||
|
willChange: "auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-center gap-2"
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
style={{
|
||||||
|
backfaceVisibility: "hidden",
|
||||||
|
transform: "translateZ(0)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{Array.from({ length: maxRating }, (_, index) => {
|
||||||
|
const starRating = index + 1;
|
||||||
|
const isFilled = starRating <= displayRating;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={index}
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"relative flex items-center justify-center transition-colors duration-150 ease-out focus:outline-none focus:ring-2 focus:ring-primary/20 rounded-lg p-2",
|
||||||
|
disabled
|
||||||
|
? "cursor-not-allowed opacity-50"
|
||||||
|
: "cursor-pointer hover:bg-yellow-50/30"
|
||||||
|
)}
|
||||||
|
onClick={() => handleStarClick(starRating)}
|
||||||
|
onMouseEnter={() => handleStarHover(starRating)}
|
||||||
|
disabled={disabled}
|
||||||
|
style={{
|
||||||
|
touchAction: "manipulation",
|
||||||
|
backfaceVisibility: "hidden",
|
||||||
|
transform: "translate3d(0, 0, 0)",
|
||||||
|
WebkitFontSmoothing: "antialiased",
|
||||||
|
minWidth: "3rem",
|
||||||
|
minHeight: "3rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Star
|
||||||
|
className={cn(
|
||||||
|
sizeClasses[size],
|
||||||
|
"transition-colors duration-150 ease-out",
|
||||||
|
isFilled
|
||||||
|
? "fill-yellow-400 text-yellow-400"
|
||||||
|
: "fill-transparent text-gray-300"
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
backfaceVisibility: "hidden",
|
||||||
|
transform: "translate3d(0, 0, 0)",
|
||||||
|
WebkitFontSmoothing: "antialiased",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showLabels && (
|
||||||
|
<div className="text-center min-h-[2.5rem] flex items-center justify-center">
|
||||||
|
{displayRating > 0 && (
|
||||||
|
<div className="text-lg font-semibold text-gray-800 bg-yellow-50 px-4 py-2 rounded-full border border-yellow-200 transition-opacity duration-150">
|
||||||
|
{currentLabel}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StarRating;
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import * as SwitchPrimitive from "@radix-ui/react-switch";
|
||||||
|
import { cn } from "@/super-admin/lib/utils";
|
||||||
|
|
||||||
|
function Switch({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<SwitchPrimitive.Root
|
||||||
|
data-slot="switch"
|
||||||
|
className={cn(
|
||||||
|
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SwitchPrimitive.Thumb
|
||||||
|
data-slot="switch-thumb"
|
||||||
|
className={cn(
|
||||||
|
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SwitchPrimitive.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Switch };
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
|
||||||
|
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="table-container"
|
||||||
|
className="relative w-full overflow-x-auto"
|
||||||
|
>
|
||||||
|
<table
|
||||||
|
data-slot="table"
|
||||||
|
className={cn("w-full caption-bottom text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||||
|
return (
|
||||||
|
<thead
|
||||||
|
data-slot="table-header"
|
||||||
|
className={cn("[&_tr]:border-b", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||||
|
return (
|
||||||
|
<tbody
|
||||||
|
data-slot="table-body"
|
||||||
|
className={cn("[&_tr:last-child]:border-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||||
|
return (
|
||||||
|
<tfoot
|
||||||
|
data-slot="table-footer"
|
||||||
|
className={cn(
|
||||||
|
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
data-slot="table-row"
|
||||||
|
className={cn(
|
||||||
|
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
data-slot="table-head"
|
||||||
|
className={cn(
|
||||||
|
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
data-slot="table-cell"
|
||||||
|
className={cn(
|
||||||
|
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableCaption({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"caption">) {
|
||||||
|
return (
|
||||||
|
<caption
|
||||||
|
data-slot="table-caption"
|
||||||
|
className={cn("text-muted-foreground mt-4 text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Table,
|
||||||
|
TableHeader,
|
||||||
|
TableBody,
|
||||||
|
TableFooter,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
TableCell,
|
||||||
|
TableCaption,
|
||||||
|
};
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import {
|
||||||
|
ColumnDef,
|
||||||
|
ColumnFiltersState,
|
||||||
|
SortingState,
|
||||||
|
VisibilityState,
|
||||||
|
flexRender,
|
||||||
|
getCoreRowModel,
|
||||||
|
getFacetedRowModel,
|
||||||
|
getFacetedUniqueValues,
|
||||||
|
getFilteredRowModel,
|
||||||
|
getSortedRowModel,
|
||||||
|
useReactTable,
|
||||||
|
} from "@tanstack/react-table";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/shared/common/ui/table";
|
||||||
|
|
||||||
|
import { DataTablePaginationFinal } from "./DataTablePaginationFinal";
|
||||||
|
import { DataTableToolbar } from "./DataTableToolbar";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { usePersistedColumnVisibility } from "@/shared/hooks/usePersistedColumnVisibility";
|
||||||
|
|
||||||
|
interface DataTableProps<TData, TValue> {
|
||||||
|
columns: ColumnDef<TData, TValue>[];
|
||||||
|
data: TData[];
|
||||||
|
tableName: string;
|
||||||
|
initialColumnVisibility?: VisibilityState;
|
||||||
|
extraToolbar?: React.ReactNode;
|
||||||
|
toolBarPosition?: "right" | "left" | "center";
|
||||||
|
isLoading?: boolean;
|
||||||
|
itemCount: number;
|
||||||
|
pageIndex: number; // <-- new
|
||||||
|
pageSize?: number; // <-- new
|
||||||
|
onPageChange: (pageIndex: number) => void; // <-- required
|
||||||
|
onPageSizeChange?: (size: number) => void; // <-- new
|
||||||
|
nextFunction: () => void;
|
||||||
|
prevFunction: () => void;
|
||||||
|
refresh?: () => void;
|
||||||
|
onGlobalFilterChange?: (value: string) => void;
|
||||||
|
/** Hide the toolbar's "Filter <tableName>..." text input. Use when the page
|
||||||
|
* already provides its own search/filter UI above the table. */
|
||||||
|
hideToolbarFilter?: boolean;
|
||||||
|
/** Disable client-side filtering when using server-side filtering */
|
||||||
|
disableClientFiltering?: boolean;
|
||||||
|
}
|
||||||
|
interface incomingHistory {
|
||||||
|
status: string;
|
||||||
|
record: { recordTypeKey: string };
|
||||||
|
id: string;
|
||||||
|
isUrgent?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdvancedTable<TData, TValue>({
|
||||||
|
columns,
|
||||||
|
data,
|
||||||
|
tableName,
|
||||||
|
initialColumnVisibility,
|
||||||
|
extraToolbar,
|
||||||
|
toolBarPosition = "right",
|
||||||
|
isLoading = false,
|
||||||
|
itemCount,
|
||||||
|
onPageChange,
|
||||||
|
onPageSizeChange,
|
||||||
|
nextFunction,
|
||||||
|
prevFunction,
|
||||||
|
pageIndex,
|
||||||
|
pageSize = 10,
|
||||||
|
refresh,
|
||||||
|
onGlobalFilterChange,
|
||||||
|
hideToolbarFilter = false,
|
||||||
|
disableClientFiltering = false,
|
||||||
|
}: DataTableProps<TData, TValue>) {
|
||||||
|
const [sorting, setSorting] = React.useState<SortingState>([]);
|
||||||
|
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const [columnVisibility, setColumnVisibility] = usePersistedColumnVisibility(
|
||||||
|
tableName,
|
||||||
|
{
|
||||||
|
letterName: false,
|
||||||
|
fileName: false,
|
||||||
|
...(initialColumnVisibility ?? {}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const [rowSelection, setRowSelection] = React.useState({});
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data,
|
||||||
|
columns,
|
||||||
|
pageCount: pageSize > 0 ? Math.ceil(itemCount / pageSize) : 1,
|
||||||
|
state: {
|
||||||
|
sorting,
|
||||||
|
columnVisibility,
|
||||||
|
rowSelection,
|
||||||
|
columnFilters,
|
||||||
|
pagination: {
|
||||||
|
pageIndex, // now controlled from props
|
||||||
|
pageSize,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
manualPagination: true,
|
||||||
|
enableRowSelection: true,
|
||||||
|
onRowSelectionChange: setRowSelection,
|
||||||
|
onSortingChange: setSorting,
|
||||||
|
onColumnFiltersChange: setColumnFilters,
|
||||||
|
onColumnVisibilityChange: setColumnVisibility,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
...(disableClientFiltering
|
||||||
|
? {}
|
||||||
|
: { getFilteredRowModel: getFilteredRowModel() }),
|
||||||
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
getFacetedRowModel: getFacetedRowModel(),
|
||||||
|
getFacetedUniqueValues: getFacetedUniqueValues(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<DataTableToolbar
|
||||||
|
table={table}
|
||||||
|
tableName={tableName}
|
||||||
|
extraToolbar={extraToolbar}
|
||||||
|
toolBarPosition={toolBarPosition}
|
||||||
|
refreshTable={refresh}
|
||||||
|
onGlobalFilterChange={onGlobalFilterChange}
|
||||||
|
hideToolbarFilter={hideToolbarFilter}
|
||||||
|
disableClientFiltering={disableClientFiltering}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="rounded-md border dark:border-gray-700 bg-white dark:bg-gray-900 overflow-x-auto font-urbanist custom-scrollbar">
|
||||||
|
<Table className="w-full text-base text-gray-700 dark:text-gray-300 font-urbanist">
|
||||||
|
<TableHeader className="bg-gray-50 dark:bg-gray-800">
|
||||||
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
<TableRow key={headerGroup.id} className="dark:border-gray-700">
|
||||||
|
{headerGroup.headers.map((header) => (
|
||||||
|
<TableHead
|
||||||
|
key={header.id}
|
||||||
|
className="text-base text-gray-500 dark:text-gray-400 dark:bg-gray-800"
|
||||||
|
>
|
||||||
|
{header.isPlaceholder
|
||||||
|
? null
|
||||||
|
: flexRender(
|
||||||
|
header.column.columnDef.header,
|
||||||
|
header.getContext(),
|
||||||
|
)}
|
||||||
|
</TableHead>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableHeader>
|
||||||
|
|
||||||
|
<TableBody>
|
||||||
|
{isLoading ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
colSpan={columns.length}
|
||||||
|
className="text-center py-6"
|
||||||
|
>
|
||||||
|
Loading...
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : table.getRowModel().rows?.length ? (
|
||||||
|
table.getRowModel().rows.map((row) => (
|
||||||
|
<TableRow
|
||||||
|
key={row.id}
|
||||||
|
data-state={row.getIsSelected() && "selected"}
|
||||||
|
className={`
|
||||||
|
transition-colors duration-150
|
||||||
|
${
|
||||||
|
((row.original as incomingHistory).isUrgent === true &&
|
||||||
|
tableName === "Pending Records") ||
|
||||||
|
((row.original as any).action === "inprogress" &&
|
||||||
|
tableName === "My Approval Records")
|
||||||
|
? "bg-yellow-200 dark:bg-yellow-900/30 hover:bg-yellow-300 dark:hover:bg-yellow-900/50"
|
||||||
|
: "hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||||
|
}
|
||||||
|
${
|
||||||
|
(((row.original as any).signStatus === "draft" &&
|
||||||
|
tableName === "Collaborations") ||
|
||||||
|
tableName === "Pending Approval Tasks") &&
|
||||||
|
"bg-primary-50 dark:bg-primary-900/20 hover:bg-primary-100 dark:hover:bg-primary-900/30 font-semibold border-l-4 border-l-primary-500 dark:border-l-primary-400"
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<TableCell key={cell.id} className="py-4">
|
||||||
|
<div className="max-h-[110px] max-w-[460px] overflow-y-auto overflow-x-auto scrollbar-hidden hover:cursor-pointer custom-scrollbar">
|
||||||
|
{flexRender(
|
||||||
|
cell.column.columnDef.cell,
|
||||||
|
cell.getContext(),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
colSpan={columns.length}
|
||||||
|
className="text-center py-6"
|
||||||
|
>
|
||||||
|
{t("common.noResult")}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
{itemCount > pageSize && (
|
||||||
|
<DataTablePaginationFinal
|
||||||
|
pageIndex={pageIndex}
|
||||||
|
pageSize={pageSize}
|
||||||
|
itemCount={itemCount}
|
||||||
|
onPageChange={onPageChange}
|
||||||
|
onPageSizeChange={onPageSizeChange}
|
||||||
|
nextFunction={nextFunction}
|
||||||
|
prevFunction={prevFunction}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { Column } from "@tanstack/react-table";
|
||||||
|
|
||||||
|
import { cn } from "@/super-admin/lib/utils";
|
||||||
|
import { Badge } from "@/shared/common/ui/badge";
|
||||||
|
import { Button } from "@/shared/common/ui/button";
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
CommandSeparator,
|
||||||
|
} from "@/shared/common/ui/command";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/shared/common/ui/popover";
|
||||||
|
import { Separator } from "@/shared/common/ui/separator";
|
||||||
|
import { CheckIcon, PlusCircle } from "lucide-react";
|
||||||
|
|
||||||
|
export type FilterOption = {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
icon?: React.ComponentType<{ className?: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface DataTableFacetedFilter<TData, TValue> {
|
||||||
|
column?: Column<TData, TValue>;
|
||||||
|
title?: string;
|
||||||
|
options: FilterOption[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DataTableFacetedFilter<TData, TValue>({
|
||||||
|
column,
|
||||||
|
title,
|
||||||
|
options,
|
||||||
|
}: DataTableFacetedFilter<TData, TValue>) {
|
||||||
|
const facets = column?.getFacetedUniqueValues();
|
||||||
|
const selectedValues = new Set(column?.getFilterValue() as string[]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm" className="h-8 border-dashed">
|
||||||
|
<PlusCircle className="mr-2 h-4 w-4" />
|
||||||
|
{title}
|
||||||
|
{selectedValues?.size > 0 && (
|
||||||
|
<>
|
||||||
|
<Separator orientation="vertical" className="mx-2 h-4" />
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="rounded-sm px-1 font-normal lg:hidden"
|
||||||
|
>
|
||||||
|
{selectedValues.size}
|
||||||
|
</Badge>
|
||||||
|
<div className="hidden space-x-1 lg:flex">
|
||||||
|
{selectedValues.size > 2 ? (
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="rounded-sm px-1 font-normal"
|
||||||
|
>
|
||||||
|
{selectedValues.size} selected
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
options
|
||||||
|
.filter((option) => selectedValues.has(option.value))
|
||||||
|
.map((option) => (
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
key={option.value}
|
||||||
|
className="rounded-sm px-1 font-normal"
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</Badge>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[200px] p-0" align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder={title} />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>No results found.</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{options.map((option) => {
|
||||||
|
const isSelected = selectedValues.has(option.value);
|
||||||
|
return (
|
||||||
|
<CommandItem
|
||||||
|
key={option.value}
|
||||||
|
onSelect={() => {
|
||||||
|
if (isSelected) {
|
||||||
|
selectedValues.delete(option.value);
|
||||||
|
} else {
|
||||||
|
selectedValues.add(option.value);
|
||||||
|
}
|
||||||
|
const filterValues = Array.from(selectedValues);
|
||||||
|
column?.setFilterValue(
|
||||||
|
filterValues.length ? filterValues : undefined
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary",
|
||||||
|
isSelected
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "opacity-50 [&_svg]:invisible"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CheckIcon className={cn("h-4 w-4")} />
|
||||||
|
</div>
|
||||||
|
{option.icon && (
|
||||||
|
<option.icon className="mr-2 h-4 w-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
<span>{option.label}</span>
|
||||||
|
{facets?.get(option.value) && (
|
||||||
|
<span className="ml-auto flex h-4 w-4 items-center justify-center font-mono text-xs">
|
||||||
|
{facets.get(option.value)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</CommandItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</CommandGroup>
|
||||||
|
{selectedValues.size > 0 && (
|
||||||
|
<>
|
||||||
|
<CommandSeparator />
|
||||||
|
<CommandGroup>
|
||||||
|
<CommandItem
|
||||||
|
onSelect={() => column?.setFilterValue(undefined)}
|
||||||
|
className="justify-center text-center"
|
||||||
|
>
|
||||||
|
Clear filters
|
||||||
|
</CommandItem>
|
||||||
|
</CommandGroup>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
|
import { Button } from "@/shared/common/ui/button";
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
|
interface DataTablePaginationProps {
|
||||||
|
pageIndex: number; // current page index (0-based)
|
||||||
|
pageSize?: number; // items per page
|
||||||
|
itemCount: number; // total number of items
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
nextFunction: () => void;
|
||||||
|
prevFunction: () => void;
|
||||||
|
/** Maximum number of numeric page buttons rendered before truncating with ellipsis. */
|
||||||
|
siblingCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ELLIPSIS = "…" as const;
|
||||||
|
|
||||||
|
type PageItem = number | typeof ELLIPSIS;
|
||||||
|
|
||||||
|
function buildPageItems(
|
||||||
|
currentPage: number,
|
||||||
|
totalPages: number,
|
||||||
|
siblingCount: number,
|
||||||
|
): PageItem[] {
|
||||||
|
// Always show first, last, current, and `siblingCount` pages around current.
|
||||||
|
// Insert ellipsis where there are gaps.
|
||||||
|
const totalNumbers = siblingCount * 2 + 5; // first + last + current + 2*siblings + 2 ellipses
|
||||||
|
|
||||||
|
if (totalPages <= totalNumbers) {
|
||||||
|
return Array.from({ length: totalPages }, (_, i) => i + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const leftSibling = Math.max(currentPage - siblingCount, 2);
|
||||||
|
const rightSibling = Math.min(currentPage + siblingCount, totalPages - 1);
|
||||||
|
const showLeftEllipsis = leftSibling > 2;
|
||||||
|
const showRightEllipsis = rightSibling < totalPages - 1;
|
||||||
|
|
||||||
|
const items: PageItem[] = [1];
|
||||||
|
if (showLeftEllipsis) items.push(ELLIPSIS);
|
||||||
|
for (let i = leftSibling; i <= rightSibling; i++) items.push(i);
|
||||||
|
if (showRightEllipsis) items.push(ELLIPSIS);
|
||||||
|
items.push(totalPages);
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DataTablePagination({
|
||||||
|
pageIndex,
|
||||||
|
pageSize = 10,
|
||||||
|
itemCount,
|
||||||
|
onPageChange,
|
||||||
|
nextFunction,
|
||||||
|
prevFunction,
|
||||||
|
siblingCount = 1,
|
||||||
|
}: DataTablePaginationProps) {
|
||||||
|
const totalPages = Math.max(1, Math.ceil(itemCount / pageSize));
|
||||||
|
const currentPage = pageIndex + 1; // 1-based for display
|
||||||
|
|
||||||
|
const canGoNext = pageIndex < totalPages - 1;
|
||||||
|
const canGoPrev = pageIndex > 0;
|
||||||
|
|
||||||
|
const pageItems = useMemo(
|
||||||
|
() => buildPageItems(currentPage, totalPages, siblingCount),
|
||||||
|
[currentPage, totalPages, siblingCount],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Edge case: hide entirely when there's nothing to paginate.
|
||||||
|
if (totalPages <= 1 || itemCount === 0) return null;
|
||||||
|
|
||||||
|
const handleNext = () => {
|
||||||
|
if (canGoNext) {
|
||||||
|
onPageChange(pageIndex + 1);
|
||||||
|
nextFunction();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePrev = () => {
|
||||||
|
if (canGoPrev) {
|
||||||
|
onPageChange(pageIndex - 1);
|
||||||
|
prevFunction();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const goToPage = (page: number) => {
|
||||||
|
const clamped = Math.min(Math.max(page, 1), totalPages);
|
||||||
|
if (clamped - 1 === pageIndex) return;
|
||||||
|
onPageChange(clamped - 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const rangeStart = pageIndex * pageSize + 1;
|
||||||
|
const rangeEnd = Math.min(rangeStart + pageSize - 1, itemCount);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav
|
||||||
|
role="navigation"
|
||||||
|
aria-label="Pagination"
|
||||||
|
className="flex flex-col gap-3 px-2 py-3 text-sm sm:flex-row sm:items-center sm:justify-between"
|
||||||
|
>
|
||||||
|
<p className="text-muted-foreground dark:text-gray-400">
|
||||||
|
Showing{" "}
|
||||||
|
<span className="font-medium text-gray-700 dark:text-gray-200">
|
||||||
|
{rangeStart}
|
||||||
|
</span>
|
||||||
|
{"–"}
|
||||||
|
<span className="font-medium text-gray-700 dark:text-gray-200">
|
||||||
|
{rangeEnd}
|
||||||
|
</span>{" "}
|
||||||
|
of{" "}
|
||||||
|
<span className="font-medium text-gray-700 dark:text-gray-200">
|
||||||
|
{itemCount}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center justify-end gap-1">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handlePrev}
|
||||||
|
disabled={!canGoPrev}
|
||||||
|
aria-label="Previous page"
|
||||||
|
className="h-8 gap-1 px-3 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
<span className="hidden sm:inline">Previous</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<ul className="flex items-center gap-1">
|
||||||
|
{pageItems.map((item, idx) => {
|
||||||
|
if (item === ELLIPSIS) {
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={`ellipsis-${idx}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
className="flex h-8 w-8 items-center justify-center text-gray-400 dark:text-gray-500"
|
||||||
|
>
|
||||||
|
{ELLIPSIS}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const isActive = item === currentPage;
|
||||||
|
return (
|
||||||
|
<li key={item}>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={isActive ? "default" : "ghost"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => goToPage(item)}
|
||||||
|
aria-current={isActive ? "page" : undefined}
|
||||||
|
aria-label={`Go to page ${item}`}
|
||||||
|
className={cn(
|
||||||
|
"h-8 min-w-8 px-2 text-sm transition-colors",
|
||||||
|
isActive
|
||||||
|
? "bg-primary-100 text-primary-800 font-bold hover:bg-primary-200 dark:bg-primary-900 dark:text-primary-300 dark:hover:bg-primary-800"
|
||||||
|
: "font-medium text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-700",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item}
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleNext}
|
||||||
|
disabled={!canGoNext}
|
||||||
|
aria-label="Next page"
|
||||||
|
className="h-8 gap-1 px-3 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700"
|
||||||
|
>
|
||||||
|
<span className="hidden sm:inline">Next</span>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
|
import { Button } from "@/shared/common/ui/button";
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/shared/common/ui/select";
|
||||||
|
import { Table } from "@tanstack/react-table";
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
interface DataTablePaginationProps<TData = any> {
|
||||||
|
table?: Table<TData>;
|
||||||
|
pageIndex?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
itemCount?: number;
|
||||||
|
onPageChange?: (page: number) => void;
|
||||||
|
onPageSizeChange?: (size: number) => void;
|
||||||
|
pageCount?: number;
|
||||||
|
nextFunction?: () => void;
|
||||||
|
prevFunction?: () => void;
|
||||||
|
/** Maximum number of numeric page buttons rendered before truncating with ellipsis. */
|
||||||
|
siblingCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ELLIPSIS = "…" as const;
|
||||||
|
|
||||||
|
type PageItem = number | typeof ELLIPSIS;
|
||||||
|
|
||||||
|
function buildPageItems(
|
||||||
|
currentPage: number,
|
||||||
|
totalPages: number,
|
||||||
|
siblingCount: number,
|
||||||
|
): PageItem[] {
|
||||||
|
const totalNumbers = siblingCount * 2 + 5;
|
||||||
|
|
||||||
|
if (totalPages <= totalNumbers) {
|
||||||
|
return Array.from({ length: totalPages }, (_, i) => i + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const leftSibling = Math.max(currentPage - siblingCount, 2);
|
||||||
|
const rightSibling = Math.min(currentPage + siblingCount, totalPages - 1);
|
||||||
|
const showLeftEllipsis = leftSibling > 2;
|
||||||
|
const showRightEllipsis = rightSibling < totalPages - 1;
|
||||||
|
|
||||||
|
const items: PageItem[] = [1];
|
||||||
|
if (showLeftEllipsis) items.push(ELLIPSIS);
|
||||||
|
for (let i = leftSibling; i <= rightSibling; i++) items.push(i);
|
||||||
|
if (showRightEllipsis) items.push(ELLIPSIS);
|
||||||
|
items.push(totalPages);
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DataTablePaginationFinal<TData>({
|
||||||
|
table,
|
||||||
|
pageIndex: pageIndexProp,
|
||||||
|
pageSize: pageSizeProp,
|
||||||
|
itemCount: itemCountProp,
|
||||||
|
onPageChange,
|
||||||
|
onPageSizeChange,
|
||||||
|
nextFunction,
|
||||||
|
prevFunction,
|
||||||
|
pageCount: pageCountProp,
|
||||||
|
siblingCount = 1,
|
||||||
|
}: DataTablePaginationProps<TData>) {
|
||||||
|
// Derive page size
|
||||||
|
const pageSize = table
|
||||||
|
? (table.getState().pagination?.pageSize ?? 10)
|
||||||
|
: (pageSizeProp ?? 10);
|
||||||
|
|
||||||
|
// Derive page index (0-based)
|
||||||
|
const pageIndex = table
|
||||||
|
? (table.getState().pagination?.pageIndex ?? 0)
|
||||||
|
: (pageIndexProp ?? 0);
|
||||||
|
|
||||||
|
// Derive total pages
|
||||||
|
const totalPages = table
|
||||||
|
? table.getPageCount()
|
||||||
|
: (pageCountProp ?? Math.max(1, Math.ceil((itemCountProp ?? 0) / pageSize)));
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const currentPage = pageIndex + 1;
|
||||||
|
|
||||||
|
const canGoNext = table ? table.getCanNextPage() : pageIndex < totalPages - 1;
|
||||||
|
const canGoPrev = table ? table.getCanPreviousPage() : pageIndex > 0;
|
||||||
|
|
||||||
|
const pageItems = useMemo(
|
||||||
|
() => buildPageItems(currentPage, totalPages, siblingCount),
|
||||||
|
[currentPage, totalPages, siblingCount],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Safely determine if the table is completely empty
|
||||||
|
const isEmpty = table
|
||||||
|
? table.getCoreRowModel().rows.length === 0 && (!itemCountProp || itemCountProp === 0)
|
||||||
|
: (itemCountProp !== undefined && itemCountProp === 0);
|
||||||
|
|
||||||
|
if (isEmpty) return null;
|
||||||
|
|
||||||
|
// Hide pagination if there is at most 1 page and no way to change page size
|
||||||
|
const hasPageSizeSelector = !!table || !!onPageSizeChange;
|
||||||
|
if (totalPages <= 1 && !hasPageSizeSelector) return null;
|
||||||
|
|
||||||
|
const handleNext = () => {
|
||||||
|
if (canGoNext) {
|
||||||
|
if (table) {
|
||||||
|
table.nextPage();
|
||||||
|
} else {
|
||||||
|
onPageChange?.(pageIndex + 1);
|
||||||
|
}
|
||||||
|
nextFunction?.();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePrev = () => {
|
||||||
|
if (canGoPrev) {
|
||||||
|
if (table) {
|
||||||
|
table.previousPage();
|
||||||
|
} else {
|
||||||
|
onPageChange?.(pageIndex - 1);
|
||||||
|
}
|
||||||
|
prevFunction?.();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const goToPage = (page: number) => {
|
||||||
|
const clamped = Math.min(Math.max(page, 1), totalPages);
|
||||||
|
if (clamped - 1 === pageIndex) return;
|
||||||
|
if (table) {
|
||||||
|
table.setPageIndex(clamped - 1);
|
||||||
|
} else {
|
||||||
|
onPageChange?.(clamped - 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePageSizeChange = (size: number) => {
|
||||||
|
if (table) {
|
||||||
|
table.setPageSize(size);
|
||||||
|
} else {
|
||||||
|
onPageSizeChange?.(size);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const rangeStart = pageIndex * pageSize + 1;
|
||||||
|
const rangeEnd = Math.min(rangeStart + pageSize - 1, itemCountProp ?? 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav
|
||||||
|
role="navigation"
|
||||||
|
aria-label="Pagination"
|
||||||
|
className="flex flex-col gap-3 px-2 py-3 text-sm sm:flex-row sm:items-center sm:justify-between"
|
||||||
|
>
|
||||||
|
{hasPageSizeSelector ? (
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<p className="text-xs sm:text-sm font-medium whitespace-nowrap">
|
||||||
|
Rows per page
|
||||||
|
</p>
|
||||||
|
<Select
|
||||||
|
value={`${pageSize}`}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
handlePageSizeChange(Number(value));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-9 sm:h-8 w-[70px] min-w-[70px] text-sm">
|
||||||
|
<SelectValue placeholder={pageSize} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent side="top">
|
||||||
|
{[10, 20, 30, 40, 50].map((size) => (
|
||||||
|
<SelectItem key={size} value={`${size}`}>
|
||||||
|
{size}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs sm:text-sm text-muted-foreground dark:text-gray-400">
|
||||||
|
Showing{" "}
|
||||||
|
<span className="font-medium text-gray-700 dark:text-gray-200">
|
||||||
|
{rangeStart}
|
||||||
|
</span>
|
||||||
|
{"–"}
|
||||||
|
<span className="font-medium text-gray-700 dark:text-gray-200">
|
||||||
|
{rangeEnd}
|
||||||
|
</span>{" "}
|
||||||
|
of{" "}
|
||||||
|
<span className="font-medium text-gray-700 dark:text-gray-200">
|
||||||
|
{itemCountProp}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center justify-end gap-1">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handlePrev}
|
||||||
|
disabled={!canGoPrev}
|
||||||
|
aria-label="Previous page"
|
||||||
|
className="h-8 gap-1 px-3 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
<span className="hidden sm:inline">Previous</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<ul className="flex items-center gap-1">
|
||||||
|
{pageItems.map((item, idx) => {
|
||||||
|
if (item === ELLIPSIS) {
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={`ellipsis-${idx}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
className="flex h-8 w-8 items-center justify-center text-gray-400 dark:text-gray-500"
|
||||||
|
>
|
||||||
|
{ELLIPSIS}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const isActive = item === currentPage;
|
||||||
|
return (
|
||||||
|
<li key={item}>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={isActive ? "default" : "ghost"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => goToPage(item)}
|
||||||
|
aria-current={isActive ? "page" : undefined}
|
||||||
|
aria-label={`Go to page ${item}`}
|
||||||
|
className={cn(
|
||||||
|
"h-8 min-w-8 px-2 text-sm transition-colors",
|
||||||
|
isActive
|
||||||
|
? "bg-primary text-primary-foreground font-bold hover:bg-primary/90 dark:bg-primary dark:text-primary-foreground"
|
||||||
|
: "font-medium text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-700",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item}
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleNext}
|
||||||
|
disabled={!canGoNext}
|
||||||
|
aria-label="Next page"
|
||||||
|
className="h-8 gap-1 px-3 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700"
|
||||||
|
>
|
||||||
|
<span className="hidden sm:inline">Next</span>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { Table } from "@tanstack/react-table";
|
||||||
|
|
||||||
|
import { Button } from "@/shared/common/ui/button";
|
||||||
|
import { Input } from "@/shared/common/ui/input";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { RotateCcw, X } from "lucide-react";
|
||||||
|
|
||||||
|
import { ReactNode } from "react";
|
||||||
|
import { DataTableFacetedFilter } from "./DataTableFacetedFilter";
|
||||||
|
import { DataTableViewOptions } from "./DataTableViewOptions";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
export interface FilterConfig {
|
||||||
|
columnKey: string;
|
||||||
|
title: string;
|
||||||
|
options: { label: string; value: string }[];
|
||||||
|
isVisible?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DataTableToolbarProps<TData> {
|
||||||
|
table: Table<TData>;
|
||||||
|
tableName: string;
|
||||||
|
toolBarPosition?: "right" | "left" | "center";
|
||||||
|
extraToolbar?: ReactNode;
|
||||||
|
filters?: FilterConfig[];
|
||||||
|
refreshTable?: () => void;
|
||||||
|
onGlobalFilterChange?: (value: string) => void;
|
||||||
|
/** Hide the built-in "Filter <tableName>..." text input. Pages that own
|
||||||
|
* their own search/filter UI (e.g. outgoing/incoming/pending/userRecords)
|
||||||
|
* pass true so the toolbar doesn't show a redundant filter input. */
|
||||||
|
hideToolbarFilter?: boolean;
|
||||||
|
/** Disable client-side filtering when using server-side filtering */
|
||||||
|
disableClientFiltering?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DataTableToolbar<TData>({
|
||||||
|
table,
|
||||||
|
tableName,
|
||||||
|
toolBarPosition = "left",
|
||||||
|
extraToolbar,
|
||||||
|
filters = [],
|
||||||
|
refreshTable,
|
||||||
|
onGlobalFilterChange,
|
||||||
|
hideToolbarFilter = false,
|
||||||
|
disableClientFiltering = false,
|
||||||
|
}: DataTableToolbarProps<TData>) {
|
||||||
|
const isFiltered = table.getState().columnFilters.length > 0;
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
{/* Filters Section */}
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{!hideToolbarFilter && (
|
||||||
|
<Input
|
||||||
|
placeholder={`Filter ${tableName}...`}
|
||||||
|
onChange={(event) => {
|
||||||
|
const value = event.target.value;
|
||||||
|
if (!disableClientFiltering) {
|
||||||
|
table.setGlobalFilter(value);
|
||||||
|
}
|
||||||
|
onGlobalFilterChange?.(value);
|
||||||
|
}}
|
||||||
|
className="h-8 w-full sm:w-[150px] lg:w-[250px]"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{filters.map(
|
||||||
|
({ columnKey, title, options, isVisible = true }) =>
|
||||||
|
isVisible &&
|
||||||
|
table.getColumn(columnKey) && (
|
||||||
|
<DataTableFacetedFilter
|
||||||
|
key={columnKey}
|
||||||
|
column={table.getColumn(columnKey)}
|
||||||
|
title={title}
|
||||||
|
options={options}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isFiltered && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => table.resetColumnFilters()}
|
||||||
|
className="h-8 px-2 lg:px-3"
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
<X className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Extra Toolbar & View Options Section */}
|
||||||
|
<div
|
||||||
|
className={`flex flex-wrap items-center gap-2 ${
|
||||||
|
toolBarPosition === "center"
|
||||||
|
? "justify-center"
|
||||||
|
: toolBarPosition === "left"
|
||||||
|
? "justify-start"
|
||||||
|
: "justify-end"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{extraToolbar}
|
||||||
|
<DataTableViewOptions table={table} refreshTable={refreshTable} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||