diff --git a/apps/cyberstorm-remix/app/commonComponents/Footer/Footer.css b/apps/cyberstorm-remix/app/commonComponents/Footer/Footer.css index 086c2db7f..b17a32dd2 100644 --- a/apps/cyberstorm-remix/app/commonComponents/Footer/Footer.css +++ b/apps/cyberstorm-remix/app/commonComponents/Footer/Footer.css @@ -119,16 +119,10 @@ margin-right: 3.5rem; padding: 5rem 0 0 5rem; - > h2 { - z-index: 1; - } - &::before { position: absolute; right: 0; bottom: 0; - - /* z-index: -1; */ height: 100%; background-image: linear-gradient( 165deg, @@ -143,7 +137,6 @@ } .manager-ad__description { - z-index: 1; flex-shrink: 0; max-width: 475px; padding-bottom: var(--space-16); @@ -171,10 +164,6 @@ min-height: 0; } - .manager-ad__get-manager-button { - z-index: 1; - } - .footnote { display: flex; align-items: center; diff --git a/apps/cyberstorm-remix/app/commonComponents/Navigation/Navigation.tsx b/apps/cyberstorm-remix/app/commonComponents/Navigation/Navigation.tsx index b474d64d0..a1fe7817b 100644 --- a/apps/cyberstorm-remix/app/commonComponents/Navigation/Navigation.tsx +++ b/apps/cyberstorm-remix/app/commonComponents/Navigation/Navigation.tsx @@ -231,7 +231,7 @@ export function DesktopLoginPopover() { return ( -
Review Package
-
+ + + + + Review Package + + } + titleContent="Review Package" + > + Changes might take several minutes to show publicly! Info shown below is always up to date. @@ -730,8 +744,8 @@ function ReviewPackageForm(props: { rootClasses="review-package__textarea" />
- -
+ + @@ -771,8 +785,8 @@ function ReviewPackageForm(props: { > Approve -
- + +
); } @@ -1003,32 +1017,15 @@ function managementTools( {packagePermissions.permissions.can_moderate ? (
{packagePermissions.permissions.can_moderate ? ( - - - - - Review Package - - } - > - - + ) : null} {/* {packagePermissions.permissions.can_view_listing_admin_page ? ( Editing page
- - Delete page - - } - > - - +
@@ -359,9 +345,12 @@ function DeletePackageWikiPageForm(props: { }); return ( -
-
Delete wiki page
-
+ Delete page} + titleContent="Delete wiki page" + > +
You are about to delete wiki page{" "}
-
-
+ + @@ -391,8 +380,8 @@ function DeletePackageWikiPageForm(props: { > Delete -
-
+ + ); } diff --git a/apps/cyberstorm-remix/app/settings/teams/Teams.css b/apps/cyberstorm-remix/app/settings/teams/Teams.css index 4df457ed8..bd5d333de 100644 --- a/apps/cyberstorm-remix/app/settings/teams/Teams.css +++ b/apps/cyberstorm-remix/app/settings/teams/Teams.css @@ -21,7 +21,6 @@ .create-team-form__body { display: flex; flex-direction: column; - gap: var(--gap-2xl); align-items: flex-start; align-self: stretch; } diff --git a/apps/cyberstorm-remix/app/settings/teams/Teams.tsx b/apps/cyberstorm-remix/app/settings/teams/Teams.tsx index c36139300..2089fe39a 100644 --- a/apps/cyberstorm-remix/app/settings/teams/Teams.tsx +++ b/apps/cyberstorm-remix/app/settings/teams/Teams.tsx @@ -12,7 +12,7 @@ import { } from "@thunderstore/cyberstorm"; import { faPlus } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { useReducer } from "react"; +import { useReducer, useState } from "react"; import { PageHeader } from "~/commonComponents/PageHeader/PageHeader"; import { useToast } from "@thunderstore/cyberstorm/src/newComponents/Toast/Provider"; import { @@ -72,23 +72,7 @@ export default function Teams() {

Teams

Manage your teams

- - Create Team - - - - - } - > - - +
{currentUser?.teams_full && currentUser.teams_full.length !== 0 ? ( @@ -198,11 +182,26 @@ function CreateTeamForm(props: { config: () => RequestConfig }) { }, }); + const [open, setOpen] = useState(false); + return ( -
-
Create team
-
-
+ + Create Team + + + + + } + > + Create Team + +
Enter the name of the team you wish to create. Team names can contain the characters a-z A-Z 0-9 _ and must not start or end with an _.
@@ -221,12 +220,17 @@ function CreateTeamForm(props: { config: () => RequestConfig }) { id="teamName" />
-
-
- + + + { + strongForm.submit().then(() => setOpen(false)); + }} + csVariant="accent" + > Create -
-
+ + ); } diff --git a/apps/cyberstorm-remix/app/settings/teams/team/tabs/Members/Members.tsx b/apps/cyberstorm-remix/app/settings/teams/team/tabs/Members/Members.tsx index b996c55e9..4a63fe661 100644 --- a/apps/cyberstorm-remix/app/settings/teams/team/tabs/Members/Members.tsx +++ b/apps/cyberstorm-remix/app/settings/teams/team/tabs/Members/Members.tsx @@ -29,7 +29,7 @@ import { getSessionTools } from "cyberstorm/security/publicEnvVariables"; import { useToast } from "@thunderstore/cyberstorm/src/newComponents/Toast/Provider"; import { type SelectOption } from "@thunderstore/cyberstorm/src/newComponents/Select/Select"; import { useStrongForm } from "cyberstorm/utils/StrongForm/useStrongForm"; -import { useReducer } from "react"; +import { useReducer, useState } from "react"; // REMIX TODO: Add check for "user has permission to see this page" export async function clientLoader({ params }: LoaderFunctionArgs) { @@ -151,34 +151,13 @@ export default function Members() { }, { value: ( - - - - - Kick - - } - popoverId={`memberKickModal-${member.username}-${index}`} - > - - + ), sortValue: 0, }, @@ -191,27 +170,11 @@ export default function Members() {

Teams

Manage your teams

- - Add Member - - - - - } - > - - +
RequestConfig; }) { const toast = useToast(); + const [open, setOpen] = useState(false); function formFieldUpdateAction( state: TeamAddMemberRequestData, @@ -283,6 +247,7 @@ function AddTeamMemberForm(props: { children: `Team member added`, duration: 4000, }); + setOpen(false); }, onSubmitError: (error) => { toast.addToast({ @@ -294,9 +259,21 @@ function AddTeamMemberForm(props: { }); return ( -
-
Add Member
-
+ + Add Member + + + + + } + > +
Enter the username of the user you wish to add to the team{" "} {props.teamName}. @@ -335,26 +312,27 @@ function AddTeamMemberForm(props: { />
-
-
+ + Add member -
-
+ + ); } AddTeamMemberForm.displayName = "AddTeamMemberForm"; function RemoveTeamMemberForm(props: { + indexKey?: string; userName: string; teamName: string; updateTrigger: () => Promise; config: () => RequestConfig; - popoverTarget: string; }) { const toast = useToast(); + const [open, setOpen] = useState(false); const kickMemberAction = ApiAction({ endpoint: teamRemoveMember, @@ -376,9 +354,21 @@ function RemoveTeamMemberForm(props: { }); return ( -
-
Confirm member removal
-
+ + + + + Kick + + } + > +
You are about to kick member{" "} @@ -386,14 +376,11 @@ function RemoveTeamMemberForm(props: { .
-
-
- - Cancel - + + + + Cancel + @@ -402,13 +389,15 @@ function RemoveTeamMemberForm(props: { params: { team_name: props.teamName, username: props.userName }, queryParams: {}, data: {}, + }).then(() => { + setOpen(false); }) } > Kick member -
-
+ + ); } diff --git a/apps/cyberstorm-remix/app/settings/teams/team/tabs/ServiceAccounts/ServiceAccounts.tsx b/apps/cyberstorm-remix/app/settings/teams/team/tabs/ServiceAccounts/ServiceAccounts.tsx index 549b321e4..62fd6402a 100644 --- a/apps/cyberstorm-remix/app/settings/teams/team/tabs/ServiceAccounts/ServiceAccounts.tsx +++ b/apps/cyberstorm-remix/app/settings/teams/team/tabs/ServiceAccounts/ServiceAccounts.tsx @@ -7,6 +7,7 @@ import { NewIcon, NewTextInput, Heading, + CodeBox, } from "@thunderstore/cyberstorm"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; @@ -121,15 +122,9 @@ export default function ServiceAccounts() { value: ( + @@ -138,45 +133,40 @@ export default function ServiceAccounts() { } csSize="small" > -
-
- Remove service account -
-
- - This cannot be undone! Related API token will stop working - immediately if the service account is removed. - -
- You are about to remove service account{" "} - - {serviceAccount.name} - - . -
+ + + This cannot be undone! Related API token will stop working + immediately if the service account is removed. + +
+ You are about to remove service account{" "} + + {serviceAccount.name} + + .
-
- { - removeServiceAccountAction({ - config: outletContext.requestConfig, - params: { - team_name: teamName, - uuid: serviceAccount.identifier, - }, - queryParams: {}, - data: {}, - }); - }} - csVariant="danger" - > - - - - Remove service account - -
-
+ + + { + removeServiceAccountAction({ + config: outletContext.requestConfig, + params: { + team_name: teamName, + uuid: serviceAccount.identifier, + }, + queryParams: {}, + data: {}, + }); + }} + csVariant="danger" + > + + + + Remove service account + + ), sortValue: 0, @@ -184,82 +174,17 @@ export default function ServiceAccounts() { ]; }); - // Add service account stuff - const [serviceAccountAdded, setServiceAccountAdded] = useState(false); - const [addedServiceAccountToken, setAddedServiceAccountToken] = useState(""); - const [addedServiceAccountNickname, setAddedServiceAccountNickname] = - useState(""); - - function onSuccess( - result: Awaited> - ) { - setServiceAccountAdded(true); - setAddedServiceAccountToken(result.api_token); - setAddedServiceAccountNickname(result.nickname); - serviceAccountRevalidate(); - } - return (

Service accounts

Your loyal servants

- - Add Service Account - - - - - } - csSize="small" - > - {serviceAccountAdded ? ( -
-
-

- New service account{" "} - {addedServiceAccountNickname} was created - successfully. It can be used with this API token: -

-
-
{addedServiceAccountToken}
- {/* */} -
- - Store this token securely, as it can't be retrieved - later, and treat it as you would treat an important - password. - -
-
- { - setAddedServiceAccountToken(""); - setAddedServiceAccountNickname(""); - setServiceAccountAdded(false); - }} - popoverTarget="serviceAccountAdd" - popoverTargetAction="hide" - > - Close - -
-
- ) : ( - - )} -
+
RequestConfig; - onSuccess: ( - result: Awaited> - ) => void; + serviceAccountRevalidate?: () => void; }) { + const [open, setOpen] = useState(false); + const [serviceAccountAdded, setServiceAccountAdded] = useState(false); + const [addedServiceAccountToken, setAddedServiceAccountToken] = useState(""); + const [addedServiceAccountNickname, setAddedServiceAccountNickname] = + useState(""); + + function onSuccess( + result: Awaited> + ) { + setServiceAccountAdded(true); + setAddedServiceAccountToken(result.api_token); + setAddedServiceAccountNickname(result.nickname); + } + const toast = useToast(); function formFieldUpdateAction( @@ -326,7 +263,8 @@ function AddServiceAccountForm(props: { >({ inputs: formInputs, submitor, - onSubmitSuccess: () => { + onSubmitSuccess: (result) => { + onSuccess(result); toast.addToast({ csVariant: "success", children: `Service account added`, @@ -342,29 +280,69 @@ function AddServiceAccountForm(props: { }, }); + const handleOpenChange = (nextOpen: boolean) => { + setOpen(nextOpen); + if (!nextOpen) { + setServiceAccountAdded(false); + setAddedServiceAccountToken(""); + setAddedServiceAccountNickname(""); + updateFormFieldState({ field: "nickname", value: "" }); + } + }; + return ( -
-
Add service account
-
-
- Enter the nickname of the service account you wish to add to the team{" "} - {props.teamName} -
-
- { - updateFormFieldState({ - field: "nickname", - value: e.target.value, - }); - }} - placeholder={"ExampleName"} - /> -
-
-
- Add Service Account -
-
+ + Add Service Account + + + + + } + csSize="small" + titleContent="Add service account" + > + {serviceAccountAdded ? ( + +

+ New service account {addedServiceAccountNickname} was + created successfully. It can be used with this API token: +

+
+ +
+ + Store this token securely, as it can't be retrieved later, and + treat it as you would treat an important password. + +
+ ) : ( + +
+ Enter the nickname of the service account you wish to add to the + team {props.teamName} +
+
+ { + updateFormFieldState({ + field: "nickname", + value: e.target.value, + }); + }} + placeholder={"ExampleName"} + /> +
+
+ )} + {serviceAccountAdded ? null : ( + + Add Service Account + + )} +
); } diff --git a/apps/cyberstorm-remix/app/settings/teams/team/tabs/Settings/Settings.tsx b/apps/cyberstorm-remix/app/settings/teams/team/tabs/Settings/Settings.tsx index 8a63937e1..a028d2ddd 100644 --- a/apps/cyberstorm-remix/app/settings/teams/team/tabs/Settings/Settings.tsx +++ b/apps/cyberstorm-remix/app/settings/teams/team/tabs/Settings/Settings.tsx @@ -22,7 +22,7 @@ import { import { ApiAction } from "@thunderstore/ts-api-react-actions"; import { NotLoggedIn } from "~/commonComponents/NotLoggedIn/NotLoggedIn"; import { useStrongForm } from "cyberstorm/utils/StrongForm/useStrongForm"; -import { useReducer } from "react"; +import { useReducer, useState } from "react"; // REMIX TODO: Make sure user is redirected of this page, if the user is not logged in export default function Settings() { @@ -67,31 +67,13 @@ export default function Settings() { If you are the owner of the team, you can only leave if the team has another owner assigned.

- - - - - Leave team - - } - > - - +
@@ -112,30 +94,12 @@ export default function Settings() { you need to archive a team with existing pages, contact Mythic#0001 on the Thunderstore Discord.

- - - - - Disband team - - } - > - - +
@@ -150,6 +114,7 @@ function LeaveTeamForm(props: { toast: ReturnType; }) { const { userName, teamName, toast, updateTrigger, config } = props; + const [open, setOpen] = useState(false); const kickMemberAction = ApiAction({ endpoint: teamRemoveMember, onSubmitSuccess: () => { @@ -170,10 +135,25 @@ function LeaveTeamForm(props: { }); return ( -
-
Leave team
-
-
+ + + + + Leave team + + } + > + + You are about to leave the team{" "} {teamName} -
-
-
+ + + @@ -199,8 +179,8 @@ function LeaveTeamForm(props: { > Leave team -
-
+ +
); } @@ -214,6 +194,8 @@ function DisbandTeamForm(props: { }) { const { teamName, toast, updateTrigger, config } = props; + const [open, setOpen] = useState(false); + function formFieldUpdateAction( state: TeamDisbandRequestData, action: { @@ -263,6 +245,7 @@ function DisbandTeamForm(props: { duration: 4000, }); updateTrigger(); + setOpen(false); }, onSubmitError: (error) => { toast.addToast({ @@ -274,9 +257,24 @@ function DisbandTeamForm(props: { }); return ( -
-
Disband team
-
+ + + + + Disband team + + } + > +
You are about to disband the team{" "} -
-
+ + { @@ -311,8 +309,8 @@ function DisbandTeamForm(props: { Disband team -
-
+ + ); } diff --git a/apps/cyberstorm-remix/app/settings/user/Account/Account.tsx b/apps/cyberstorm-remix/app/settings/user/Account/Account.tsx index 45bce990a..2e0fd86cb 100644 --- a/apps/cyberstorm-remix/app/settings/user/Account/Account.tsx +++ b/apps/cyberstorm-remix/app/settings/user/Account/Account.tsx @@ -7,16 +7,13 @@ import { NewTextInput, } from "@thunderstore/cyberstorm"; import { faTrashCan } from "@fortawesome/pro-solid-svg-icons"; -import { - UserAccountDeleteRequestData, - userDelete, -} from "../../../../../../packages/thunderstore-api/src"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { NotLoggedIn } from "~/commonComponents/NotLoggedIn/NotLoggedIn"; import { type OutletContextShape } from "~/root"; import { useToast } from "@thunderstore/cyberstorm/src/newComponents/Toast/Provider"; import { useStrongForm } from "cyberstorm/utils/StrongForm/useStrongForm"; import { useReducer } from "react"; +import { userDelete } from "@thunderstore/thunderstore-api"; export default function Account() { const outletContext = useOutletContext() as OutletContextShape; @@ -66,6 +63,10 @@ export default function Account() { ); } +type UserAccountDeleteRequestData = { + verification: string; +}; + function DeleteAccountForm(props: { currentUser: OutletContextShape["currentUser"]; requestConfig: OutletContextShape["requestConfig"]; @@ -94,11 +95,13 @@ function DeleteAccountForm(props: { async function submitor(data: typeof formInputs): Promise { if (!props.currentUser || !props.currentUser.username) throw new Error("User not logged in"); + if (data.verification !== props.currentUser.username) + throw new Error("Verification input does not match username"); return await userDelete({ config: props.requestConfig, params: { username: props.currentUser.username }, queryParams: {}, - data: { verification: data.verification }, + data: {}, }); } diff --git a/apps/storybook/src/stories/cyberstormComponents/Modal.css b/apps/storybook/src/stories/cyberstormComponents/Modal.css new file mode 100644 index 000000000..1c9f25bbb --- /dev/null +++ b/apps/storybook/src/stories/cyberstormComponents/Modal.css @@ -0,0 +1,13 @@ +@layer storybook-stories { + .custom-modal-title { + background-color: red; + } + + .custom-modal-body { + background-color: red; + } + + .custom-modal-footer { + background-color: red; + } +} diff --git a/apps/storybook/src/stories/cyberstormComponents/Modal.stories.tsx b/apps/storybook/src/stories/cyberstormComponents/Modal.stories.tsx index a18d5cd40..388ec8010 100644 --- a/apps/storybook/src/stories/cyberstormComponents/Modal.stories.tsx +++ b/apps/storybook/src/stories/cyberstormComponents/Modal.stories.tsx @@ -1,11 +1,10 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import "@thunderstore/cyberstorm-theme"; -import { Modal, NewButton, type ModalProps } from "@thunderstore/cyberstorm"; +import { Modal, NewButton } from "@thunderstore/cyberstorm"; import { ModalSizesList, ModalVariantsList, } from "@thunderstore/cyberstorm-theme/src/components"; -import { useEffect } from "react"; const meta = { title: "Cyberstorm/Modal", @@ -14,65 +13,158 @@ const meta = { argTypes: { csVariant: { control: "select", options: ModalVariantsList }, csSize: { control: "select", options: ModalSizesList }, + onOpenChange: { action: "onOpenChange" }, + open: { control: "boolean" }, + trigger: { control: false }, + disableTitle: { control: "boolean" }, + disableBody: { control: "boolean" }, + disableFooter: { control: "boolean" }, + disableExit: { control: "boolean" }, + disableDefaultSubComponents: { control: "boolean" }, + titleContent: { control: "text" }, + footerContent: { control: "text" }, + ariaDescribedby: { control: "text" }, }, args: { csVariant: ModalVariantsList[0], csSize: ModalSizesList[0], + trigger: Open modal, + disableTitle: false, + disableBody: false, + disableFooter: false, + disableExit: false, + disableDefaultSubComponents: false, + titleContent: "Modal Title", + footerContent: "Modal Footer", + ariaDescribedby: "modal-description", }, + render: (args) => , } satisfies Meta; export default meta; type Story = StoryObj; export const Default: Story = { + args: {}, +}; + +export const DisabledTitleDefaultSubComponents: Story = { args: { - popoverId: "modal-1", - trigger: ( - - Open modal - - ), + disableTitle: true, + open: true, + }, +}; +export const DisabledBodyDefaultSubComponents: Story = { + args: { + disableBody: true, + open: true, + }, +}; +export const DisabledFooterDefaultSubComponents: Story = { + args: { + disableFooter: true, + open: true, + }, +}; +export const DisabledExitDefaultSubComponents: Story = { + args: { + disableExit: true, + open: true, + }, +}; +export const DisabledAllDefaultSubComponents: Story = { + args: { + disableDefaultSubComponents: true, + open: true, }, - render: (args) => , }; -function DefaultComponent(props: { args: ModalProps }) { - const { args } = props; - useEffect(() => { - const modalElement = document.getElementById(args.popoverId); - if (!modalElement) return; - modalElement.showPopover(); - }, [args.popoverId]); - return ( - -
Modal content
-
- ); -} +export const Variants: Story = { + render: () => { + const size = "small"; + return ( + <> + {ModalVariantsList.map((variant) => ( + + + {size}-{variant} + + + {size}-{variant} + + + {size}-{variant} + + + ))} + + ); + }, +}; -export const SmallSize: Story = { - args: { - popoverId: "modal-2", - trigger: ( - - Open modal - - ), - csSize: ModalSizesList[1], +export const Sizes: Story = { + render: () => { + const variant = "default"; + return ( + <> + {ModalSizesList.map((size) => ( + + + {size}-{variant} + + + {size}-{variant} + + + {size}-{variant} + + + ))} + + ); + }, +}; + +export const ModalTitle: Story = { + render: (args) => { + return ( + + + Custom Modal Title + + + ); + }, +}; + +export const ModalBody: Story = { + render: (args) => { + return ( + + Custom Modal Body + + ); }, - render: (args) => , }; -function SmallSizeComponent(props: { args: ModalProps }) { - const { args } = props; - useEffect(() => { - const modalElement = document.getElementById(args.popoverId); - if (!modalElement) return; - modalElement.showPopover(); - }, [args.popoverId]); - return ( - -
Modal content
-
- ); -} +export const ModalFooter: Story = { + render: (args) => { + return ( + + + Custom Modal Footer + + + ); + }, +}; diff --git a/packages/cyberstorm/package.json b/packages/cyberstorm/package.json index fbe25793a..c108d8621 100644 --- a/packages/cyberstorm/package.json +++ b/packages/cyberstorm/package.json @@ -23,7 +23,7 @@ "@fortawesome/pro-solid-svg-icons": "6.6.0", "@fortawesome/react-fontawesome": "^0.2.2", "@radix-ui/react-checkbox": "^1.1.1", - "@radix-ui/react-dialog": "^1.1.1", + "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.1", "@radix-ui/react-radio-group": "^1.2.0", "@radix-ui/react-select": "^2.1.1", diff --git a/packages/cyberstorm/src/newComponents/BreadCrumbs/BreadCrumbs.css b/packages/cyberstorm/src/newComponents/BreadCrumbs/BreadCrumbs.css index 7f2f87e90..ee223fba2 100644 --- a/packages/cyberstorm/src/newComponents/BreadCrumbs/BreadCrumbs.css +++ b/packages/cyberstorm/src/newComponents/BreadCrumbs/BreadCrumbs.css @@ -70,7 +70,6 @@ } > *:first-child { - z-index: 1; padding-left: 0; > svg { diff --git a/packages/cyberstorm/src/newComponents/Modal/Modal.css b/packages/cyberstorm/src/newComponents/Modal/Modal.css index fc7ba2050..af341b768 100644 --- a/packages/cyberstorm/src/newComponents/Modal/Modal.css +++ b/packages/cyberstorm/src/newComponents/Modal/Modal.css @@ -1,8 +1,24 @@ @layer components { - .modal { + .modal__overlay { + position: fixed; + background: rgb(0 0 0 / 0.6); + backdrop-filter: blur(10px); + animation: overlay-show 150ms cubic-bezier(0.16, 1, 0.3, 1); + inset: 0; + } + + .modal__content { position: fixed; top: 50%; left: 50%; + display: flex; + flex-direction: column; + + /* gap: var(--modal-content-gap); */ + align-items: flex-start; + align-self: stretch; + justify-content: center; + padding: var(--modal-content-padding); border: var(--modal-border); border-radius: var(--modal-border-radius); color: var(--modal-color); @@ -10,26 +26,75 @@ box-shadow: var(--modal-box-shadow); transform: translate(-50%, -50%); + animation: content-show 150ms cubic-bezier(0.16, 1, 0.3, 1); + } - &::backdrop { - background: rgb(0 0 0 / 0.6); - backdrop-filter: blur(10px); - } + .modal__content:focus { + outline: none; } - .modal__content { + .modal__title { display: flex; - flex-direction: column; - gap: var(--modal-content-gap); - align-items: flex-start; + align-items: center; align-self: stretch; - justify-content: center; - padding: var(--modal-content-padding); + height: 60px; + padding: var(--space-8) var(--space-8) var(--space-8) var(--space-32); + + padding-right: 92px; + border-bottom: 1px solid var(--modal-border-color--default); + color: var(--color-text-primary); + font-weight: var(--font-weight-bold); + font-size: 21px; + font-style: normal; + line-height: 120%; /* 25.2px */ } .modal__button { - position: absolute; + position: fixed; top: 0.5rem; right: 0.5rem; } + + .modal__body { + display: flex; + flex-direction: column; + gap: var(--gap-md); + align-items: flex-start; + align-self: stretch; + max-height: 800px; + padding: var(--space-32); + overflow-y: auto; + } + + .modal__footer { + display: flex; + gap: var(--gap-md); + align-items: center; + align-self: stretch; + justify-content: flex-end; + padding: var(--space-16) var(--space-24); + border-top: 1px solid var(--modal-border-color--default); + } + + @keyframes overlay-show { + from { + opacity: 0; + } + + to { + opacity: 1; + } + } + + @keyframes content-show { + from { + transform: translate(-50%, -48%) scale(0.96); + opacity: 0; + } + + to { + transform: translate(-50%, -50%) scale(1); + opacity: 1; + } + } } diff --git a/packages/cyberstorm/src/newComponents/Modal/Modal.tsx b/packages/cyberstorm/src/newComponents/Modal/Modal.tsx index e21f95fdc..c5dc080fc 100644 --- a/packages/cyberstorm/src/newComponents/Modal/Modal.tsx +++ b/packages/cyberstorm/src/newComponents/Modal/Modal.tsx @@ -1,8 +1,9 @@ -import { type ReactNode } from "react"; import { - Frame, - type FrameModalProps, -} from "../../primitiveComponents/Frame/Frame"; + cloneElement, + isValidElement, + type PropsWithChildren, + type ReactNode, +} from "react"; import "./Modal.css"; import { NewButton, NewIcon } from "../.."; import { type ModalVariants } from "@thunderstore/cyberstorm-theme/src/components"; @@ -10,46 +11,384 @@ import { classnames, componentClasses } from "../../utils/utils"; import { faXmarkLarge } from "@fortawesome/pro-solid-svg-icons"; import { type ModalSizes } from "@thunderstore/cyberstorm-theme/src/components/Modal/Modal"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import * as Dialog from "@radix-ui/react-dialog"; +import { type ButtonComponentProps } from "../Button/Button"; -export interface ModalProps extends Omit { +/** + * Props for the `Modal` component. + * + * The modal is built on top of Radix UI Dialog and supports both + * controlled and uncontrolled usage. Provide `open` and + * `onOpenChange` to control it programmatically, or omit them and + * pass a `trigger` element to let Radix manage state internally. + */ +export interface ModalProps + extends PropsWithChildren, + Pick { + /** + * Optional element that acts as the opener for the modal. + * Rendered through Radix `Dialog.Trigger` with `asChild` so your + * element receives the trigger behavior without extra wrappers. + * + * Omit this when you control the modal via `open`/`onOpenChange` only. + */ trigger?: ReactNode; + /** + * Visual variant (theme-driven). + * @default "default" + */ csVariant?: ModalVariants; + /** + * Visual size (theme-driven). + * @default "medium" + */ csSize?: ModalSizes; + /** + * Additional class names applied to the modal content container. + */ + contentClasses?: string; + /** + * When true, prevents rendering the default Title region. + * Useful when you supply your own `Modal.Title` as a child. + */ + disableTitle?: boolean; + /** + * When true, prevents rendering the default Body region. + * Use this if you want to fully control child layout yourself. + */ + disableBody?: boolean; + /** + * When true, prevents rendering the default Footer region. + */ + disableFooter?: boolean; + /** + * When true, prevents rendering the default Exit (close) button. + * You can provide your own `Modal.Exit` as a child to replace it. + */ + disableExit?: boolean; + /** + * When true, disables the default subcomponent parsing entirely and + * renders children exactly as provided. + */ + disableDefaultSubComponents?: boolean; + /** + * Shorthand content for the title region; rendered as ``. + */ + titleContent?: ReactNode; + /** + * Shorthand content for the footer region; rendered as ``. + */ + footerContent?: ReactNode; + /** + * Value forwarded to `aria-describedby` on the dialog content. + * Provide the id of an element that describes the dialog. + */ + ariaDescribedby?: string; } +// TODO: Style system compatibility is currently degraded, improve to a agreed upon level according to specifications -// TODO: Add storybook story -// TODO: Currently the same modal can't be used in 2 different places in the same page. Fix that somehow +/** + * Accessible modal dialog built on Radix UI Dialog. + * + * This component provides an opinionated layout with optional subcomponents: + * `Modal.Exit`, `Modal.Title`, `Modal.Body`, and `Modal.Footer`. You can either + * let the component render sensible defaults (via `titleContent`/`footerContent`), + * or provide the subcomponents directly as children for full control. + * + * Control model: + * - Uncontrolled: omit `open`/`onOpenChange` and provide a `trigger` element. + * - Controlled: pass `open` and `onOpenChange` to manage visibility from state. + * + * Styling is driven by `csVariant` and `csSize` which map to the theme system. + * + * @param props - See `ModalProps` for full list of options. + * + * @example Uncontrolled with trigger and shorthand title/footer + * ```tsx + * function Example() { + * return ( + * Open modal} + * titleContent="Example modal" + * footerContent={ + * <> + * Cancel + * Confirm + * + * } + * > + *

Body content goes here.

+ *
+ * ); + * } + * ``` + * + * @example Controlled (programmatic open/close via state) + * ```tsx + * import { useState } from "react"; + * + * function ControlledExample() { + * const [open, setOpen] = useState(false); + * + * return ( + * <> + * // Programmatically open the modal without a trigger + * setOpen(true)}> + * Open programmatically + * + * + * + *

Controlled body content.

+ * + * setOpen(false)}> + * Close + * + * + *
+ * + * ); + * } + * ``` + * + * @example Using explicit subcomponents and custom footer structure + * ```tsx + * function CustomStructure() { + * return ( + * Open}> + * + * Custom title + * + *
Rich body content…
+ *
Rich body content…
+ *
Rich body content…
+ *
+ * + * Dismiss + * + *
+ * ); + * } + * ``` + */ export function Modal(props: ModalProps) { - const { children, csVariant = "default", csSize = "medium", trigger } = props; + const { + children, + csVariant = "default", + csSize = "medium", + trigger, + disableTitle, + disableBody, + disableFooter, + disableExit, + disableDefaultSubComponents, + titleContent, + footerContent, + ariaDescribedby, + } = props; + + const filteredChildren: ReactNode[] = []; + + let exit = ; + + let title = titleContent ? {titleContent} : null; + + let body = undefined; + + let footer = footerContent ? ( + {footerContent} + ) : null; + + if (!disableDefaultSubComponents && children) { + for (const child of children instanceof Array ? children : [children]) { + if (child == null) continue; + if (isValidElement(child)) { + const childDisplayName = + typeof child.type === "function" && + Object.prototype.hasOwnProperty.call(child.type, "displayName") + ? (child.type as { displayName?: string }).displayName + : ""; + if (!disableExit && childDisplayName === ModalExit.displayName) { + exit = child; + continue; + } + if (!disableTitle && childDisplayName === ModalTitle.displayName) { + title = child; + continue; + } + if (!disableFooter && childDisplayName === ModalFooter.displayName) { + footer = child; + continue; + } + if (!disableBody && childDisplayName === ModalBody.displayName) { + body = child; + continue; + } + filteredChildren.push(child); + } else { + filteredChildren.push(child); + } + } + } return ( - <> - {trigger} - - - - - - -
{children}
- - + + {trigger ? {trigger} : null} + + + + {disableDefaultSubComponents ? ( + children + ) : ( + <> + {disableExit ? null : exit} + {disableTitle ? null : title} + {disableBody ? ( + filteredChildren + ) : body ? ( + body + ) : ( + {filteredChildren} + )} + {disableFooter ? null : footer} + + )} + + + + ); } Modal.displayName = "Modal"; + +function ModalExit(props: { + modalCloseProps?: Dialog.DialogCloseProps; + asChild?: boolean; + buttonProps?: ButtonComponentProps; +}) { + return ( + + + + + + + + ); +} + +ModalExit.displayName = "ModalExit"; + +function ModalTitle(props: Dialog.DialogTitleProps) { + return ( + + ); +} + +ModalTitle.displayName = "ModalTitle"; + +function ModalBody( + props: { asChild?: boolean; className?: string } & PropsWithChildren +) { + if (props.asChild) { + if (isValidElement(props.children)) { + return cloneElement( + props.children as React.ReactElement<{ className?: string }>, + { + className: classnames( + "modal__body", + (props.children.props as { className?: string }).className, + props.className + ), + } + ); + } else { + console.warn("Modal.Body child is not valid for usage with asChild"); + return ( +
+ {props.children} +
+ ); + } + } + + return ( +
+ {props.children} +
+ ); +} + +ModalBody.displayName = "ModalBody"; + +function ModalFooter( + props: { asChild?: boolean; className?: string } & PropsWithChildren +) { + if (props.asChild) { + if (isValidElement(props.children)) { + return cloneElement( + props.children as React.ReactElement<{ className?: string }>, + { + className: classnames( + "modal__footer", + (props.children.props as { className?: string }).className, + props.className + ), + } + ); + } else { + console.warn("Modal.Footer child is not valid for usage with asChild"); + return ( +
+ {props.children} +
+ ); + } + } + + return ( +
+ {props.children} +
+ ); +} + +ModalFooter.displayName = "ModalFooter"; + +// Expose subcomponents for easier access +Modal.Root = Dialog.Root; +Modal.Trigger = Dialog.Trigger; +Modal.Portal = Dialog.Portal; +Modal.Overlay = Dialog.Overlay; +Modal.Content = Dialog.Content; +Modal.Close = Dialog.Close; +Modal.Exit = ModalExit; +Modal.Title = ModalTitle; +Modal.Body = ModalBody; +Modal.Footer = ModalFooter; diff --git a/packages/cyberstorm/src/primitiveComponents/Frame/Frame.tsx b/packages/cyberstorm/src/primitiveComponents/Frame/Frame.tsx index fe569f169..6b47ea812 100644 --- a/packages/cyberstorm/src/primitiveComponents/Frame/Frame.tsx +++ b/packages/cyberstorm/src/primitiveComponents/Frame/Frame.tsx @@ -230,18 +230,19 @@ export const Frame = memo(function Frame( <> {Children.map(children, (child) => { if (React.isValidElement(child)) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return cloneElement(child as React.ReactElement, { - className: classnames( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (child.props as any).className, - "icon", - csMode === "inline" ? "icon--inline" : null, - rootClasses - ), - ref: svgIconRef, - ...svgFProps, - }); + return cloneElement( + child as React.ReactElement<{ className?: string }>, + { + className: classnames( + (child.props as { className?: string }).className, + "icon", + csMode === "inline" ? "icon--inline" : null, + rootClasses + ), + ref: svgIconRef, + ...svgFProps, + } + ); } else { return null; } @@ -258,17 +259,18 @@ export const Frame = memo(function Frame( > {Children.map(children, (child) => { if (React.isValidElement(child)) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return cloneElement(child as React.ReactElement, { - className: classnames( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (child.props as any).className, - "icon", - "icon--inline", - rootClasses - ), - ...svgFProps, - }); + return cloneElement( + child as React.ReactElement<{ className?: string }>, + { + className: classnames( + (child.props as { className?: string }).className, + "icon", + "icon--inline", + rootClasses + ), + ...svgFProps, + } + ); } else { return null; } @@ -286,15 +288,18 @@ export const Frame = memo(function Frame( {Children.map(children, (child) => { if (React.isValidElement(child)) { // eslint-disable-next-line @typescript-eslint/no-explicit-any - return cloneElement(child as React.ReactElement, { - className: classnames( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (child.props as any).className, - "icon", - rootClasses - ), - ...svgFProps, - }); + return cloneElement( + child as React.ReactElement<{ className?: string }>, + { + className: classnames( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (child.props as { className?: string }).className, + "icon", + rootClasses + ), + ...svgFProps, + } + ); } else { return null; } diff --git a/yarn.lock b/yarn.lock index e5e97dd00..c93180b60 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2061,6 +2061,11 @@ resolved "https://registry.yarnpkg.com/@radix-ui/primitive/-/primitive-1.1.2.tgz#83f415c4425f21e3d27914c12b3272a32e3dae65" integrity sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA== +"@radix-ui/primitive@1.1.3": + version "1.1.3" + resolved "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz#e2dbc13bdc5e4168f4334f75832d7bdd3e2de5ba" + integrity sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg== + "@radix-ui/react-arrow@1.1.7": version "1.1.7" resolved "https://registry.yarnpkg.com/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz#e14a2657c81d961598c5e72b73dd6098acc04f09" @@ -2102,20 +2107,20 @@ resolved "https://registry.yarnpkg.com/@radix-ui/react-context/-/react-context-1.1.2.tgz#61628ef269a433382c364f6f1e3788a6dc213a36" integrity sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA== -"@radix-ui/react-dialog@^1.1.1": - version "1.1.14" - resolved "https://registry.yarnpkg.com/@radix-ui/react-dialog/-/react-dialog-1.1.14.tgz#4c69c80c258bc6561398cfce055202ea11075107" - integrity sha512-+CpweKjqpzTmwRwcYECQcNYbI8V9VSQt0SNFKeEBLgfucbsLssU6Ppq7wUdNXEGb573bMjFhVjKVll8rmV6zMw== +"@radix-ui/react-dialog@1.1.15": + version "1.1.15" + resolved "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz#1de3d7a7e9a17a9874d29c07f5940a18a119b632" + integrity sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw== dependencies: - "@radix-ui/primitive" "1.1.2" + "@radix-ui/primitive" "1.1.3" "@radix-ui/react-compose-refs" "1.1.2" "@radix-ui/react-context" "1.1.2" - "@radix-ui/react-dismissable-layer" "1.1.10" - "@radix-ui/react-focus-guards" "1.1.2" + "@radix-ui/react-dismissable-layer" "1.1.11" + "@radix-ui/react-focus-guards" "1.1.3" "@radix-ui/react-focus-scope" "1.1.7" "@radix-ui/react-id" "1.1.1" "@radix-ui/react-portal" "1.1.9" - "@radix-ui/react-presence" "1.1.4" + "@radix-ui/react-presence" "1.1.5" "@radix-ui/react-primitive" "2.1.3" "@radix-ui/react-slot" "1.2.3" "@radix-ui/react-use-controllable-state" "1.2.2" @@ -2138,6 +2143,17 @@ "@radix-ui/react-use-callback-ref" "1.1.1" "@radix-ui/react-use-escape-keydown" "1.1.1" +"@radix-ui/react-dismissable-layer@1.1.11": + version "1.1.11" + resolved "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz#e33ab6f6bdaa00f8f7327c408d9f631376b88b37" + integrity sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg== + dependencies: + "@radix-ui/primitive" "1.1.3" + "@radix-ui/react-compose-refs" "1.1.2" + "@radix-ui/react-primitive" "2.1.3" + "@radix-ui/react-use-callback-ref" "1.1.1" + "@radix-ui/react-use-escape-keydown" "1.1.1" + "@radix-ui/react-dropdown-menu@^2.1.1": version "2.1.15" resolved "https://registry.yarnpkg.com/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.15.tgz#f507320de8e11bc1e671a6ec0c27a7a89e725131" @@ -2156,6 +2172,11 @@ resolved "https://registry.yarnpkg.com/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.2.tgz#4ec9a7e50925f7fb661394460045b46212a33bed" integrity sha512-fyjAACV62oPV925xFCrH8DR5xWhg9KYtJT4s3u54jxp+L/hbpTY2kIeEFFbFe+a/HCE94zGQMZLIpVTPVZDhaA== +"@radix-ui/react-focus-guards@1.1.3": + version "1.1.3" + resolved "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz#2a5669e464ad5fde9f86d22f7fdc17781a4dfa7f" + integrity sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw== + "@radix-ui/react-focus-scope@1.1.7": version "1.1.7" resolved "https://registry.yarnpkg.com/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz#dfe76fc103537d80bf42723a183773fd07bfb58d" @@ -2228,6 +2249,14 @@ "@radix-ui/react-compose-refs" "1.1.2" "@radix-ui/react-use-layout-effect" "1.1.1" +"@radix-ui/react-presence@1.1.5": + version "1.1.5" + resolved "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz#5d8f28ac316c32f078afce2996839250c10693db" + integrity sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ== + dependencies: + "@radix-ui/react-compose-refs" "1.1.2" + "@radix-ui/react-use-layout-effect" "1.1.1" + "@radix-ui/react-primitive@2.1.3": version "2.1.3" resolved "https://registry.yarnpkg.com/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz#db9b8bcff49e01be510ad79893fb0e4cda50f1bc"