Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

[BOOKINGSG-6090-timetable][JZ|FY] Added TimeTable component and enhanced popover #581

Merged
merged 18 commits into from
Oct 9, 2024
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ export * from "./time-slot-bar-week";
export * from "./time-slot-week-view";
export * from "./timeline";
export * from "./timepicker";
export * from "./timetable";
export * from "./toast";
export * from "./toggle";
export * from "./tooltip";
Expand Down
10 changes: 8 additions & 2 deletions src/popover-v2/popover-trigger.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ export const PopoverTrigger = ({
position = "top",
zIndex,
rootNode,
customOffset,
delay,
onPopoverAppear,
onPopoverDismiss,
...otherProps
Expand All @@ -45,7 +47,7 @@ export const PopoverTrigger = ({
placement: position,
whileElementsMounted: autoUpdate,
middleware: [
offset(16),
offset(customOffset ?? 16),
flip(),
shift({
limiter: limitShift(),
Expand All @@ -71,7 +73,10 @@ export const PopoverTrigger = ({
const hover = useHover(context, {
enabled: trigger === "hover",
// short window to enter the floating element without it closing
delay: { close: 500 },
delay: {
open: delay?.open ?? 0,
close: delay?.close ?? 500,
},
});

const { getReferenceProps, getFloatingProps } = useInteractions([
Expand Down Expand Up @@ -136,6 +141,7 @@ export const PopoverTrigger = ({
}}
style={{
...floatingStyles,
outline: "none",
zIndex: zIndex ?? parentZIndex,
}}
{...getFloatingProps()}
Expand Down
5 changes: 5 additions & 0 deletions src/popover-v2/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ export interface PopoverV2TriggerProps {
* the popover may not be visible. Specify the parent element here instead
*/
rootNode?: RefObject<HTMLElement> | undefined;
customOffset?: number | undefined;
// in milliseconds
delay?:
| { open?: number | undefined; close?: number | undefined }
| undefined;
onPopoverAppear?: (() => void) | undefined;
onPopoverDismiss?: (() => void) | undefined;
}
Expand Down
10 changes: 10 additions & 0 deletions src/timetable/const.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export const ROW_BAR_COLOR_SEQUENCE = [
"#FFE6BB",
"#D8EFEB",
"#E6EAFE",
"#FAE4E5",
"#D3EEFC",
] as const; // Assert to be a readonly tuple
export const ROW_CELL_GAP = 2;
export const ROW_INTERVAL = 15;
export type RowBarColors = (typeof ROW_BAR_COLOR_SEQUENCE)[number];
2 changes: 2 additions & 0 deletions src/timetable/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from "./timetable";
export * from "./types";
40 changes: 40 additions & 0 deletions src/timetable/timetable-navigator/timetable-navigator.style.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import styled, { css } from "styled-components";
import { Color } from "../../color";
import { IconButton } from "../../icon-button";
import { Text } from "../../text";

export const StyledRefreshButton = styled(IconButton)<{ $isLoading: boolean }>`
color: ${Color.Neutral[3]};
@keyframes spin {
xyukianesa marked this conversation as resolved.
Show resolved Hide resolved
100% {
-webkit-transform: rotate(360deg);
transform: rotate(360deg);
}
}
svg {
${(props) => {
if (props.$isLoading) {
return css`
-webkit-animation: spin 4s linear infinite;
-moz-animation: spin 4s linear infinite;
`;
}
}}
}
`;

export const NavigationHeaderWrapper = styled.div`
width: 252px;
padding-bottom: 1rem;
`;

export const NavigationHeaderSubtitleWrapper = styled.div`
display: flex;
align-items: center;
justify-content: center;
gap: 0.625rem;
`;

export const StyledResultText = styled(Text.H6)`
color: ${Color.Neutral[3]};
`;
107 changes: 107 additions & 0 deletions src/timetable/timetable-navigator/timetable-navigator.tsx
xyukianesa marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { RefreshIcon } from "@lifesg/react-icons";
import { MutableRefObject } from "react";
import { DateNavigator } from "../../date-navigator/date-navigator";
import {
NavigationHeaderSubtitleWrapper,
NavigationHeaderWrapper,
StyledRefreshButton,
StyledResultText,
} from "./timetable-navigator.style";

interface TimeTableNavigatorProps {
selectedDate: string;
isLoading: boolean;
tableContainerRef: MutableRefObject<HTMLDivElement>;
minDate?: string | undefined;
maxDate?: string | undefined;
totalRecords?: number | undefined;
onLeftArrowClick?: (currentDate: string) => void | undefined;
onRightArrowClick?: (currentDate: string) => void | undefined;
onRefresh?: (() => void) | undefined;
}

export const TimeTableNavigator = ({
selectedDate,
isLoading,
tableContainerRef,
...optionalProps
xyukianesa marked this conversation as resolved.
Show resolved Hide resolved
}: TimeTableNavigatorProps) => {
// =============================================================================
// EVENT HANDLERS
// =============================================================================

const scrollToTop = () => {
if (tableContainerRef.current) {
tableContainerRef.current.scrollTop = 0;
}
};

const onRefresh = () => {
xyukianesa marked this conversation as resolved.
Show resolved Hide resolved
if (!optionalProps.onRefresh) return;
scrollToTop();
optionalProps.onRefresh();
};

const handleRightArrowClick = (date: string) => {
scrollToTop();
optionalProps.onRightArrowClick(date);
};

const handleLeftArrowClick = (date: string) => {
scrollToTop();
optionalProps.onLeftArrowClick(date);
};
// =============================================================================
// RENDER FUNCTIONS
// =============================================================================
const renderRecordsSection = () => {
if (optionalProps.totalRecords === undefined) return <></>;
return (
<NavigationHeaderSubtitleWrapper id="timetable-records-wrapper-id">
<StyledResultText
id="timetable-records-results-id"
xyukianesa marked this conversation as resolved.
Show resolved Hide resolved
data-testid="timetable-records-results"
weight={"semibold"}
>
{optionalProps.totalRecords} results found
</StyledResultText>
{optionalProps.onRefresh && (
<StyledRefreshButton
id="timetable-records-refresh-btn-id"
data-testid="timetable-records-refresh-btn"
styleType="light"
sizeType="small"
disabled={isLoading}
onClick={onRefresh}
$isLoading={isLoading}
>
<RefreshIcon />
</StyledRefreshButton>
)}
</NavigationHeaderSubtitleWrapper>
);
};

return (
<NavigationHeaderWrapper id="timetable-navigation-header-wrapper-id">
{
<DateNavigator
selectedDate={selectedDate}
isLoading={isLoading}
{...optionalProps}
onRightArrowClick={
optionalProps.onRightArrowClick
? handleRightArrowClick
: undefined
}
onLeftArrowClick={
optionalProps.onLeftArrowClick
? handleLeftArrowClick
: undefined
}
/>
}
{renderRecordsSection()}
</NavigationHeaderWrapper>
);
};
6 changes: 6 additions & 0 deletions src/timetable/timetable-row/row-bar.style.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import styled from "styled-components";

export const RowCellContainer = styled.div`
display: flex;
width: 100%;
`;
121 changes: 121 additions & 0 deletions src/timetable/timetable-row/row-bar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import dayjs from "dayjs";
import { MutableRefObject } from "react";
import { RowBarColors } from "../const";
import {
CustomPopoverProps,
TimeTableRowCellData,
TimeTableRowData,
} from "../types";
import { RowCellContainer } from "./row-bar.style";
import { RowCell } from "./row-cell";

interface RowBarProps extends TimeTableRowData {
timetableMinTime: string;
timetableMaxTime: string;
intervalWidth: number;
containerRef: MutableRefObject<HTMLDivElement>;
rowBarColor: RowBarColors;
outsideOpHoursCellCustomPopover?: CustomPopoverProps | undefined;
}

export const RowBar = ({
id,
timetableMinTime,
timetableMaxTime,
rowMinTime = timetableMinTime,
rowMaxTime = timetableMaxTime,
rowCells,
rowBarColor,
intervalWidth,
containerRef,
outsideOpHoursCellCustomPopover,
...otherProps
}: RowBarProps) => {
// =============================================================================
// CONST, STATE, REF
// =============================================================================
const testId = otherProps["data-testid"] || `${id}-row`;
xyukianesa marked this conversation as resolved.
Show resolved Hide resolved
const rowCellArray: TimeTableRowCellData[] = [];

xyukianesa marked this conversation as resolved.
Show resolved Hide resolved
// ===========================================================================
// HELPER FUNCTIONS
// ===========================================================================

const isOnTheSameHour = (
time1: dayjs.Dayjs,
time2: dayjs.Dayjs
): boolean => {
return time1.get("hour") == time2.get("hour");
};

// Handle non-op before hours
if (dayjs(timetableMinTime, "HH:mm").isBefore(dayjs(rowMinTime, "HH:mm"))) {
rowCellArray.push({
id,
startTime: timetableMinTime,
endTime: rowMinTime,
status: "blocked",
customPopover: outsideOpHoursCellCustomPopover,
});
}

rowCells.forEach((cell, index) => {
const { endTime } = cell;
rowCellArray.push(cell);

const nextSlotStartTime = dayjs(
rowCells[index + 1]?.startTime || rowMaxTime, // Get next cell start time, if next cell don't exist, use current row max time
"HH:mm"
);
const parsedEndTime = dayjs(endTime, "HH:mm");

let curr = parsedEndTime;
while (curr.isBefore(nextSlotStartTime)) {
if (!isOnTheSameHour(curr, nextSlotStartTime)) {
const nextHour = curr.add(1, "hour").startOf("hour"); // Round to the next hour
rowCellArray.push({
id,
startTime: curr.format("HH:mm").toString(),
endTime: nextHour.format("HH:mm").toString(),
status: "disabled",
});
curr = nextHour;
} else {
rowCellArray.push({
id,
startTime: curr.format("HH:mm").toString(),
endTime: nextSlotStartTime.format("HH:mm").toString(),
status: "disabled",
});
curr = nextSlotStartTime;
}
}
});

// Handle non-op after hours
if (dayjs(timetableMaxTime, "HH:mm").isAfter(dayjs(rowMaxTime, "HH:mm"))) {
rowCellArray.push({
id,
startTime: rowMaxTime,
endTime: timetableMaxTime,
status: "blocked",
customPopover: outsideOpHoursCellCustomPopover,
});
}

return (
<RowCellContainer data-testid={testId} {...otherProps}>
{rowCellArray.map((cell, index) => {
return (
<RowCell
key={`${index}-row-cell-key`}
{...cell}
intervalWidth={intervalWidth}
rowBarColor={rowBarColor}
containerRef={containerRef}
/>
);
})}
</RowCellContainer>
);
};
Loading
Loading