Skip to content

Commit 2352bc0

Browse files
committed
refactor(web): simplify recurrence scope dialog
1 parent db85542 commit 2352bc0

3 files changed

Lines changed: 160 additions & 111 deletions

File tree

Lines changed: 11 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
import { OverlayPanel } from "@web/components/OverlayPanel/OverlayPanel";
1+
import {
2+
OverlayPanel,
3+
OverlayPanelActionButton,
4+
OverlayPanelActions,
5+
} from "@web/components/OverlayPanel/OverlayPanel";
26

37
interface LogoutConfirmationDialogProps {
48
isOpen: boolean;
@@ -20,22 +24,14 @@ export function LogoutConfirmationDialog({
2024
onDismiss={onCancel}
2125
variant="modal"
2226
>
23-
<div className="flex w-full justify-end gap-3">
24-
<button
25-
className="h-11 rounded border border-border-primary bg-panel-badge-bg px-4 text-sm text-text-lighter transition-colors hover:bg-white/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-primary focus-visible:ring-offset-2 focus-visible:ring-offset-panel-bg disabled:pointer-events-none disabled:opacity-50"
26-
onClick={onCancel}
27-
type="button"
28-
>
27+
<OverlayPanelActions>
28+
<OverlayPanelActionButton onClick={onCancel}>
2929
Cancel
30-
</button>
31-
<button
32-
className="h-11 rounded bg-accent-primary px-4 text-sm text-text-dark transition hover:brightness-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-primary focus-visible:ring-offset-2 focus-visible:ring-offset-panel-bg disabled:pointer-events-none disabled:opacity-50"
33-
onClick={onConfirm}
34-
type="button"
35-
>
30+
</OverlayPanelActionButton>
31+
<OverlayPanelActionButton variant="primary" onClick={onConfirm}>
3632
Log out
37-
</button>
38-
</div>
33+
</OverlayPanelActionButton>
34+
</OverlayPanelActions>
3935
</OverlayPanel>
4036
);
4137
}

packages/web/src/components/OverlayPanel/OverlayPanel.tsx

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
import clsx from "clsx";
2-
import { type ReactNode, useEffect, useId, useRef } from "react";
2+
import {
3+
type ButtonHTMLAttributes,
4+
type ReactNode,
5+
useEffect,
6+
useId,
7+
useRef,
8+
} from "react";
39

410
const FOCUSABLE_SELECTOR =
511
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
@@ -140,3 +146,38 @@ export const OverlayPanel = ({
140146
</div>
141147
);
142148
};
149+
150+
interface OverlayPanelActionsProps {
151+
children: ReactNode;
152+
}
153+
154+
export const OverlayPanelActions = ({ children }: OverlayPanelActionsProps) => (
155+
<div className="flex w-full justify-end gap-3">{children}</div>
156+
);
157+
158+
interface OverlayPanelActionButtonProps
159+
extends ButtonHTMLAttributes<HTMLButtonElement> {
160+
variant?: "primary" | "secondary";
161+
}
162+
163+
export const OverlayPanelActionButton = ({
164+
children,
165+
className,
166+
type = "button",
167+
variant = "secondary",
168+
...buttonProps
169+
}: OverlayPanelActionButtonProps) => (
170+
<button
171+
className={clsx(
172+
"h-11 rounded px-4 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-primary focus-visible:ring-offset-2 focus-visible:ring-offset-panel-bg disabled:pointer-events-none disabled:opacity-50",
173+
variant === "primary"
174+
? "bg-accent-primary text-text-dark transition hover:brightness-110"
175+
: "border border-border-primary bg-panel-badge-bg text-text-lighter transition-colors hover:bg-panel-bg",
176+
className,
177+
)}
178+
type={type}
179+
{...buttonProps}
180+
>
181+
{children}
182+
</button>
183+
);
Lines changed: 107 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -1,125 +1,137 @@
1-
import {
2-
FloatingFocusManager,
3-
FloatingOverlay,
4-
FloatingPortal,
5-
useClick,
6-
useDismiss,
7-
useFloating,
8-
useInteractions,
9-
useRole,
10-
} from "@floating-ui/react";
11-
import { useCallback, useMemo, useState } from "react";
12-
import { Priorities } from "@core/constants/core.constants";
1+
import { useCallback, useState } from "react";
132
import { RecurringEventUpdateScope } from "@core/types/event.types";
143
import { DirtyParser } from "@web/common/parsers/dirty.parser";
15-
import { theme } from "@web/common/styles/theme";
4+
import { type Schema_GridEvent } from "@web/common/types/web.event.types";
5+
import {
6+
OverlayPanel,
7+
OverlayPanelActionButton,
8+
OverlayPanelActions,
9+
} from "@web/components/OverlayPanel/OverlayPanel";
1610
import { selectDraft } from "@web/ducks/events/selectors/draft.selectors";
1711
import { useAppSelector } from "@web/store/store.hooks";
18-
import { SaveSection } from "@web/views/Forms/EventForm/SaveSection/SaveSection";
19-
import { StyledEventForm } from "@web/views/Forms/EventForm/styled";
2012
import { useDraftContext } from "@web/views/Week/components/Draft/context/useDraftContext";
2113

22-
const UPDATE_SCOPE_OPTIONS: Array<[string, RecurringEventUpdateScope]> = [
23-
["this", RecurringEventUpdateScope.THIS_EVENT],
24-
["this-and-following", RecurringEventUpdateScope.THIS_AND_FOLLOWING_EVENTS],
25-
["all", RecurringEventUpdateScope.ALL_EVENTS],
14+
const UPDATE_SCOPE_OPTIONS: RecurringEventUpdateScope[] = [
15+
RecurringEventUpdateScope.THIS_EVENT,
16+
RecurringEventUpdateScope.THIS_AND_FOLLOWING_EVENTS,
17+
RecurringEventUpdateScope.ALL_EVENTS,
18+
];
19+
20+
const RECURRENCE_CHANGED_UPDATE_SCOPE_OPTIONS: RecurringEventUpdateScope[] = [
21+
RecurringEventUpdateScope.THIS_AND_FOLLOWING_EVENTS,
22+
RecurringEventUpdateScope.ALL_EVENTS,
2623
];
2724

25+
const updateScopeOptionClassName =
26+
"flex min-h-11 cursor-pointer items-center gap-3 rounded px-3 text-base text-text-lighter transition-colors hover:bg-panel-badge-bg";
27+
28+
const selectedUpdateScopeOptionClassName = "bg-panel-badge-bg";
29+
30+
const radioDotClassName =
31+
"relative flex size-[18px] flex-none rounded-full border-2 border-border-secondary transition-colors after:absolute after:inset-0 after:m-auto after:size-2 after:scale-0 after:rounded-full after:bg-accent-primary after:transition-transform peer-checked:border-accent-primary peer-checked:after:scale-100 peer-focus-visible:ring-2 peer-focus-visible:ring-accent-primary peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-panel-bg";
32+
2833
export function RecurringEventUpdateScopeDialog() {
2934
const {
3035
confirmation,
3136
state: { draft },
3237
} = useDraftContext();
33-
const { isRecurrenceUpdateScopeDialogOpen } = confirmation;
34-
const { setRecurrenceUpdateScopeDialogOpen } = confirmation;
35-
const { onUpdateScopeChange } = confirmation;
36-
const reduxDraft = useAppSelector(selectDraft);
37-
const { UNASSIGNED } = Priorities;
38-
const priority = draft?.priority ?? reduxDraft?.priority ?? UNASSIGNED;
38+
const {
39+
isRecurrenceUpdateScopeDialogOpen,
40+
setRecurrenceUpdateScopeDialogOpen,
41+
onUpdateScopeChange,
42+
} = confirmation;
43+
if (!isRecurrenceUpdateScopeDialogOpen) return null;
44+
45+
return (
46+
<RecurringEventUpdateScopeDialogContent
47+
draft={draft}
48+
onUpdateScopeChange={onUpdateScopeChange}
49+
setRecurrenceUpdateScopeDialogOpen={setRecurrenceUpdateScopeDialogOpen}
50+
/>
51+
);
52+
}
53+
54+
interface RecurringEventUpdateScopeDialogContentProps {
55+
draft: Schema_GridEvent | null;
56+
onUpdateScopeChange: (applyTo: RecurringEventUpdateScope) => void;
57+
setRecurrenceUpdateScopeDialogOpen: (isOpen: boolean) => void;
58+
}
3959

60+
function RecurringEventUpdateScopeDialogContent({
61+
draft,
62+
onUpdateScopeChange,
63+
setRecurrenceUpdateScopeDialogOpen,
64+
}: RecurringEventUpdateScopeDialogContentProps) {
65+
const reduxDraft = useAppSelector(selectDraft);
4066
const currentDraft = draft ?? reduxDraft;
4167
const recurrenceChanged =
4268
currentDraft && reduxDraft
4369
? DirtyParser.recurrenceChanged(currentDraft, reduxDraft)
4470
: false;
71+
const options = recurrenceChanged
72+
? RECURRENCE_CHANGED_UPDATE_SCOPE_OPTIONS
73+
: UPDATE_SCOPE_OPTIONS;
74+
const [fallbackScope] = options;
4575

46-
const { context, refs, floatingStyles } = useFloating({
47-
open: isRecurrenceUpdateScopeDialogOpen,
48-
onOpenChange: setRecurrenceUpdateScopeDialogOpen,
49-
strategy: "absolute",
50-
placement: "bottom",
51-
transform: true,
52-
});
53-
54-
const [value, setValue] = useState<RecurringEventUpdateScope>(
55-
RecurringEventUpdateScope.THIS_EVENT,
56-
);
57-
58-
const click = useClick(context);
59-
const role = useRole(context);
60-
const dismiss = useDismiss(context, { outsidePressEvent: "mousedown" });
61-
const interactions = useInteractions([click, role, dismiss]);
76+
const [selectedScope, setSelectedScope] =
77+
useState<RecurringEventUpdateScope>(fallbackScope);
78+
const activeScope = options.includes(selectedScope)
79+
? selectedScope
80+
: fallbackScope;
6281

63-
const options = useMemo<Array<[string, RecurringEventUpdateScope]>>(() => {
64-
if (!recurrenceChanged) return UPDATE_SCOPE_OPTIONS;
65-
66-
return UPDATE_SCOPE_OPTIONS.slice(1);
67-
}, [recurrenceChanged]);
82+
const closeDialog = useCallback(() => {
83+
setRecurrenceUpdateScopeDialogOpen(false);
84+
}, [setRecurrenceUpdateScopeDialogOpen]);
6885

6986
const onSubmitHandler = useCallback(() => {
70-
onUpdateScopeChange(value);
71-
setValue(RecurringEventUpdateScope.THIS_EVENT);
72-
}, [value, onUpdateScopeChange]);
73-
74-
if (!isRecurrenceUpdateScopeDialogOpen) return null;
87+
onUpdateScopeChange(activeScope);
88+
setSelectedScope(RecurringEventUpdateScope.THIS_EVENT);
89+
}, [activeScope, onUpdateScopeChange]);
7590

7691
return (
77-
<FloatingPortal>
78-
<FloatingOverlay
79-
className="dialog-overlay"
80-
style={{ background: "rgba(0, 0, 0, 0.8)", zIndex: 4 }}
81-
lockScroll
92+
<OverlayPanel
93+
title="Apply changes to"
94+
onDismiss={closeDialog}
95+
variant="modal"
96+
>
97+
<div
98+
role="radiogroup"
99+
aria-label="Apply changes to"
100+
className="flex w-full flex-col gap-1"
82101
>
83-
<FloatingFocusManager context={context}>
84-
<div
85-
ref={refs.setFloating}
86-
style={{ ...floatingStyles, top: "50%", left: "50%" }}
87-
{...interactions.getFloatingProps()}
88-
>
89-
<StyledEventForm role="form" priority={priority}>
90-
<fieldset style={{ borderRadius: theme.shape.borderRadius }}>
91-
<legend>Apply Changes To</legend>
102+
{options.map((option) => {
103+
const isSelected = activeScope === option;
92104

93-
{options.map(([id, option]) => (
94-
<div key={id}>
95-
<input
96-
type="radio"
97-
name="recurring-event-update-scope"
98-
value={option}
99-
onChange={() => setValue(option)}
100-
checked={value === option}
101-
id={`recurring-event-update-scope-select-${id}`}
102-
/>
103-
104-
<label
105-
htmlFor={`recurring-event-update-scope-select--${id}`}
106-
>
107-
{option}
108-
</label>
109-
</div>
110-
))}
111-
</fieldset>
112-
113-
<SaveSection
114-
saveText="Ok"
115-
priority={priority}
116-
onSubmit={onSubmitHandler}
117-
onCancel={() => setRecurrenceUpdateScopeDialogOpen(false)}
105+
return (
106+
<label
107+
key={option}
108+
className={`${updateScopeOptionClassName} ${
109+
isSelected ? selectedUpdateScopeOptionClassName : ""
110+
}`}
111+
>
112+
<input
113+
type="radio"
114+
name="recurring-event-update-scope"
115+
value={option}
116+
checked={isSelected}
117+
onChange={() => setSelectedScope(option)}
118+
className="peer sr-only"
118119
/>
119-
</StyledEventForm>
120-
</div>
121-
</FloatingFocusManager>
122-
</FloatingOverlay>
123-
</FloatingPortal>
120+
<span aria-hidden="true" className={radioDotClassName} />
121+
{option}
122+
</label>
123+
);
124+
})}
125+
</div>
126+
127+
<OverlayPanelActions>
128+
<OverlayPanelActionButton onClick={closeDialog}>
129+
Cancel
130+
</OverlayPanelActionButton>
131+
<OverlayPanelActionButton variant="primary" onClick={onSubmitHandler}>
132+
Ok
133+
</OverlayPanelActionButton>
134+
</OverlayPanelActions>
135+
</OverlayPanel>
124136
);
125137
}

0 commit comments

Comments
 (0)