Skip to content

Commit

Permalink
Merge pull request #581 from LifeSG/BOOKINGSG-6090-timetable
Browse files Browse the repository at this point in the history
[BOOKINGSG-6090-timetable][JZ|FY] Added TimeTable component and enhanced popover
  • Loading branch information
qroll authored Oct 9, 2024
2 parents 7dea2f2 + e794442 commit 1828bb2
Show file tree
Hide file tree
Showing 24 changed files with 10,016 additions and 2 deletions.
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
11 changes: 9 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 @@ -40,12 +42,13 @@ export const PopoverTrigger = ({
const isMobile = useMediaQuery({
maxWidth: MediaWidths.mobileL,
});

const { refs, floatingStyles, context } = useFloating({
open: visible,
placement: position,
whileElementsMounted: autoUpdate,
middleware: [
offset(16),
offset(customOffset ?? 16),
flip(),
shift({
limiter: limitShift(),
Expand All @@ -71,7 +74,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 +142,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
14 changes: 14 additions & 0 deletions src/timetable/const.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
export const ROW_BAR_COLOR_SEQUENCE = [
"#FFE6BB",
"#D8EFEB",
"#E6EAFE",
"#FAE4E5",
"#D3EEFC",
] as const; // Assert to be a readonly tuple
export type RowBarColors = (typeof ROW_BAR_COLOR_SEQUENCE)[number];
export const ROW_CELL_GAP = 2;
export const ROW_INTERVAL = 15;
export const MIN_INTERVAL_WIDTH = 21;
export const ROW_HEADER_WIDTH = 252;
export const ROW_HEIGHT = 68;
export const MIN_HOURLY_INTERVAL_WIDTH = 84;
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";
41 changes: 41 additions & 0 deletions src/timetable/timetable-navigator/timetable-navigator.style.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import styled, { css, keyframes } from "styled-components";
import { Color } from "../../color";
import { IconButton } from "../../icon-button";
import { Text } from "../../text";
import { ROW_HEADER_WIDTH } from "../const";

const spin = keyframes`
100% {
-webkit-transform: rotate(360deg);
transform: rotate(360deg);
}
`;

export const StyledRefreshButton = styled(IconButton)<{ $loading: boolean }>`
color: ${Color.Neutral[3]};
svg {
${(props) => {
if (props.$loading) {
return css`
animation: ${spin} 4s linear infinite;
`;
}
}}
}
`;

export const NavigationHeaderWrapper = styled.div`
width: ${ROW_HEADER_WIDTH}px;
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]};
`;
105 changes: 105 additions & 0 deletions src/timetable/timetable-navigator/timetable-navigator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
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;
loading: 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,
loading,
tableContainerRef,
totalRecords,
onLeftArrowClick,
onRightArrowClick,
onRefresh,
...otherProps
}: TimeTableNavigatorProps) => {
// =============================================================================
// EVENT HANDLERS
// =============================================================================

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

const handleRefresh = () => {
if (!onRefresh) return;
scrollToTop();
onRefresh();
};

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

const handleLeftArrowClick = (date: string) => {
scrollToTop();
onLeftArrowClick(date);
};
// =============================================================================
// RENDER FUNCTIONS
// =============================================================================
const renderRecordsSection = () => {
if (totalRecords === undefined) return <></>;
return (
<NavigationHeaderSubtitleWrapper>
<StyledResultText
data-testid="timetable-records-results"
weight={"semibold"}
>
{totalRecords} results found
</StyledResultText>
{onRefresh && (
<StyledRefreshButton
data-testid="timetable-records-refresh-btn"
styleType="light"
sizeType="small"
disabled={loading}
onClick={handleRefresh}
$loading={loading}
>
<RefreshIcon />
</StyledRefreshButton>
)}
</NavigationHeaderSubtitleWrapper>
);
};

return (
<NavigationHeaderWrapper>
{
<DateNavigator
selectedDate={selectedDate}
loading={loading}
{...otherProps}
onRightArrowClick={
onRightArrowClick ? handleRightArrowClick : undefined
}
onLeftArrowClick={
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%;
`;
134 changes: 134 additions & 0 deletions src/timetable/timetable-row/row-bar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import dayjs from "dayjs";
import { MutableRefObject, useMemo } from "react";
import { RowBarColors } from "../const";
import { 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;
}

export const RowBar = ({
id,
timetableMinTime,
timetableMaxTime,
rowMinTime = timetableMinTime,
rowMaxTime = timetableMaxTime,
rowCells,
rowBarColor,
intervalWidth,
containerRef,
outOfRangeCellPopover,
...otherProps
}: RowBarProps) => {
// =============================================================================
// CONST, STATE, REF
// =============================================================================
const testId = otherProps["data-testid"] || "timetable-row";

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

const rowCellArray = useMemo(() => {
const rowCellArray: TimeTableRowCellData[] = [];

// 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: outOfRangeCellPopover,
});
}

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: outOfRangeCellPopover,
});
}

return rowCellArray;
}, [
id,
timetableMinTime,
timetableMaxTime,
rowMinTime,
rowMaxTime,
rowCells,
outOfRangeCellPopover,
]);

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

0 comments on commit 1828bb2

Please sign in to comment.