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 all 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
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
xyukianesa marked this conversation as resolved.
Show resolved Hide resolved
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