diff --git a/frontend/src/js/entity-history/ContentControl.tsx b/frontend/src/js/entity-history/ContentControl.tsx index f36349178a..ac6bb891f2 100644 --- a/frontend/src/js/entity-history/ContentControl.tsx +++ b/frontend/src/js/entity-history/ContentControl.tsx @@ -5,10 +5,10 @@ import { faInfo, } from "@fortawesome/free-solid-svg-icons"; import { memo, useMemo, useState } from "react"; -import { ToggleButtonGroup } from "react-aria-components"; import { useTranslation } from "react-i18next"; import { Icon } from "../ui-components/Icon"; import { ToggleButton } from "../ui-components/ToggleButton"; +import { ToggleButtonGroup } from "../ui-components/ToggleButtonGroup"; import { Tooltip, TooltipTrigger } from "../ui-components/Tooltip"; export type ContentType = @@ -56,7 +56,6 @@ const ContentControl = ({ value, onChange }: Props) => { return ( value[o.key]).map((o) => o.key)} diff --git a/frontend/src/js/entity-history/DetailControl.tsx b/frontend/src/js/entity-history/DetailControl.tsx index 9b860615a7..466e96c9f7 100644 --- a/frontend/src/js/entity-history/DetailControl.tsx +++ b/frontend/src/js/entity-history/DetailControl.tsx @@ -5,22 +5,19 @@ import { faCircleDot, } from "@fortawesome/free-solid-svg-icons"; import { type Dispatch, memo, type SetStateAction, useMemo } from "react"; -import { type Key, ToggleButtonGroup } from "react-aria-components"; +import type { Key } from "react-aria-components"; import { useTranslation } from "react-i18next"; -import { tv } from "tailwind-variants"; import { Icon } from "../ui-components/Icon"; import { ToggleButton } from "../ui-components/ToggleButton"; +import { ToggleButtonGroup } from "../ui-components/ToggleButtonGroup"; import { Tooltip, TooltipTrigger } from "../ui-components/Tooltip"; -const root = tv({ base: "flex flex-col items-center" }); - const detailLevels: DetailLevel[] = ["summary", "detail", "full"]; const isDetailLevel = (key: Key): key is DetailLevel => detailLevels.some((level) => level === key); export type DetailLevel = "summary" | "detail" | "full"; interface Props { - className?: string; detailLevel: DetailLevel; setDetailLevel: Dispatch>; } @@ -53,30 +50,27 @@ const useButtonConfig = () => { ); }; -export const DetailControl = memo( - ({ className, detailLevel, setDetailLevel }: Props) => { - const navOptions = useButtonConfig(); - return ( - { - const [key] = keys; - if (key !== undefined && isDetailLevel(key)) setDetailLevel(key); - }} - > - {navOptions.map(({ value, icon, tooltip }) => ( - - - - - {tooltip} - - ))} - - ); - }, -); +export const DetailControl = memo(({ detailLevel, setDetailLevel }: Props) => { + const navOptions = useButtonConfig(); + return ( + { + const [key] = keys; + if (key !== undefined && isDetailLevel(key)) setDetailLevel(key); + }} + > + {navOptions.map(({ value, icon, tooltip }) => ( + + + + + {tooltip} + + ))} + + ); +}); diff --git a/frontend/src/js/external-forms/form-concept-group/FormConceptGroup.tsx b/frontend/src/js/external-forms/form-concept-group/FormConceptGroup.tsx index bebf938b3a..71b5fdb4d3 100644 --- a/frontend/src/js/external-forms/form-concept-group/FormConceptGroup.tsx +++ b/frontend/src/js/external-forms/form-concept-group/FormConceptGroup.tsx @@ -22,7 +22,8 @@ import { import DropzoneWithFileInput, { type DragItemFile, } from "../../ui-components/DropzoneWithFileInput"; -import ToggleButtonGroup from "../../ui-components/ToggleButtonGroup"; +import { ToggleButton } from "../../ui-components/ToggleButton"; +import { ToggleButtonGroup } from "../../ui-components/ToggleButtonGroup"; import UploadConceptListModal from "../../upload-concept-list-modal/UploadConceptListModal"; import type { ConceptListDefaults as ConceptListDefaultsType } from "../config-types"; import { Description } from "../form-components/Description"; @@ -363,19 +364,22 @@ const FormConceptGroup = (props: Props) => { {t("externalForms.common.connectedWith")}: { + segmented + size="sm" + selectionMode="single" + disallowEmptySelection + selectedKeys={[props.value[i].connector]} + onSelectionChange={(keys) => { + const [connector] = keys; + if (typeof connector !== "string") return; props.onChange( - setValueProperties(props.value, i, { - connector: val, - }), + setValueProperties(props.value, i, { connector }), ); }} - options={[ - { value: "OR", label: t("common.or") }, - { value: "AND", label: t("common.and") }, - ]} - /> + > + {t("common.or")} + {t("common.and")} + )} ( - ({ - label: option.label[locale] || "", - value: option.value, - }))} - value={ - /* Because we're essentially adding an extra dynamic field to FormConceptGroupT +
+ )[ - field.rowPrefixField!.name - ] - } - onChange={(value) => - onChange([ - ...fieldValue.slice(0, i), - { - ...fieldValue[i], - [field.rowPrefixField!.name]: value, - }, - ...fieldValue.slice(i + 1), - ]) - } - /> + [ + (row as unknown as Record)[ + field.rowPrefixField!.name + ], + ] + } + onSelectionChange={(keys) => { + const [key] = keys; + if (typeof key !== "string") return; + onChange([ + ...fieldValue.slice(0, i), + { + ...fieldValue[i], + [field.rowPrefixField!.name]: key, + }, + ...fieldValue.slice(i + 1), + ]); + }} + > + {field.rowPrefixField!.options.map((option) => ( + + {option.label[locale] || ""} + + ))} + +
) : undefined } diff --git a/frontend/src/js/standard-query-editor/SecondaryIdSelector.tsx b/frontend/src/js/standard-query-editor/SecondaryIdSelector.tsx index e936e390de..aef575bf87 100644 --- a/frontend/src/js/standard-query-editor/SecondaryIdSelector.tsx +++ b/frontend/src/js/standard-query-editor/SecondaryIdSelector.tsx @@ -9,7 +9,9 @@ import { exists } from "../common/helpers/exists"; import { nodeIsConceptQueryNode } from "../model/node"; import { Icon } from "../ui-components/Icon"; import InfoTooltip from "../ui-components/InfoTooltip"; -import ToggleButtonGroup from "../ui-components/ToggleButtonGroup"; +import { ToggleButton } from "../ui-components/ToggleButton"; +import { ToggleButtonGroup } from "../ui-components/ToggleButtonGroup"; +import { Tooltip, TooltipTrigger } from "../ui-components/Tooltip"; import { setSelectedSecondaryId } from "./actions"; import type { StandardQueryStateT } from "./queryReducer"; @@ -150,7 +152,7 @@ const SecondaryIdSelectorUI = memo( value, onChange, }: { - options: { label: string; value: string }[]; + options: { label: string; value: string; description?: string }[]; value: string | null; onChange: (value: string) => void; }) => { @@ -167,10 +169,23 @@ const SecondaryIdSelectorUI = memo( + segmented + size="sm" + selectionMode="single" + disallowEmptySelection + selectedKeys={[value || "standard"]} + onSelectionChange={(keys) => { + const [key] = keys; + if (typeof key === "string") onChange(key); + }} + > + {options.map(({ value: id, label, description }) => ( + + {label} + {description} + + ))} + ); }, diff --git a/frontend/src/js/ui-components/InputRange.tsx b/frontend/src/js/ui-components/InputRange.tsx index 5eaafe025a..261fa58232 100644 --- a/frontend/src/js/ui-components/InputRange.tsx +++ b/frontend/src/js/ui-components/InputRange.tsx @@ -4,7 +4,8 @@ import type { CurrencyConfigT } from "../api/types"; import { exists } from "../common/helpers/exists"; import InputPlain from "./InputPlain/InputPlain"; import InputRangeHeader from "./InputRangeHeader"; -import ToggleButtonGroup from "./ToggleButtonGroup"; +import { ToggleButton } from "./ToggleButton"; +import { ToggleButtonGroup } from "./ToggleButtonGroup"; const container = tv({ base: ["flex flex-row", "w-full", "-mt-[3px]"] }); @@ -118,13 +119,19 @@ const InputRange = ({ tooltip={tooltip} /> onSwitchMode(mode as ModeT)} - options={[ - { value: "range", label: t("inputRange.range") }, - { value: "exact", label: t("inputRange.exact") }, - ]} - /> + segmented + size="sm" + selectionMode="single" + disallowEmptySelection + selectedKeys={[mode || "range"]} + onSelectionChange={(keys) => { + const [key] = keys; + if (key === "range" || key === "exact") onSwitchMode(key); + }} + > + {t("inputRange.range")} + {t("inputRange.exact")} +
{isRangeMode ? ( <> diff --git a/frontend/src/js/ui-components/ToggleButton.tsx b/frontend/src/js/ui-components/ToggleButton.tsx index 3ced06a0e0..d7035ae68b 100644 --- a/frontend/src/js/ui-components/ToggleButton.tsx +++ b/frontend/src/js/ui-components/ToggleButton.tsx @@ -7,6 +7,7 @@ import { tv } from "tailwind-variants"; import { buttonStyle } from "./Button"; import { Icon } from "./Icon"; +import { useToggleButtonGroup } from "./ToggleButtonGroup"; // while selected, the button is bold and shows its highlight color const toggleStyle = tv({ @@ -17,7 +18,31 @@ const toggleStyle = tv({ primary: "data-selected:text-primary-500", danger: "data-selected:text-red", }, + // a segment of a segmented control: bordered, recessed while off, white + // while on, joined to its neighbors along the group's orientation + segmented: { + true: [ + "rounded-none", + "border-gray-500", + "bg-gray-50 text-gray-500", + "hover:bg-white", + "data-selected:bg-white", + ], + }, + orientation: { horizontal: "", vertical: "" }, }, + compoundVariants: [ + { + segmented: true, + orientation: "horizontal", + class: "first:rounded-l last:rounded-r not-first:-ml-px", + }, + { + segmented: true, + orientation: "vertical", + class: "first:rounded-t last:rounded-b not-first:-mt-px", + }, + ], }); interface CommonProps @@ -45,8 +70,9 @@ const isIconOnly = (children: ReactNode) => { /** * A button whose look reflects a state that is on or off, in Button's look. * react-aria's ToggleButton underneath: `isSelected` / `onChange`, and it - * works inside a ToggleButtonGroup and as a tooltip trigger. Pressing may - * flip the state or open an editor for it. + * works as a tooltip trigger. Inside a ToggleButtonGroup it is keyed by `id`, + * takes the group's size unless it has its own, and joins a segmented group + * as one of its segments. Pressing may flip the state or open an editor for it. * * * @@ -58,16 +84,21 @@ export const ToggleButton = ({ highlight = "primary", children, ...props -}: ToggleButtonProps) => ( - - {children} - -); +}: ToggleButtonProps) => { + const group = useToggleButtonGroup(); + return ( + + {children} + + ); +}; diff --git a/frontend/src/js/ui-components/ToggleButtonGroup.stories.tsx b/frontend/src/js/ui-components/ToggleButtonGroup.stories.tsx new file mode 100644 index 0000000000..74b3cbcfd5 --- /dev/null +++ b/frontend/src/js/ui-components/ToggleButtonGroup.stories.tsx @@ -0,0 +1,149 @@ +import { + faBullseye, + faCircle, + faCircleDot, + faEuroSign, + faFolder, + faInfo, +} from "@fortawesome/free-solid-svg-icons"; +import type { Meta, StoryObj } from "@storybook/react"; + +import { Icon } from "./Icon"; +import { ToggleButton } from "./ToggleButton"; +import { ToggleButtonGroup } from "./ToggleButtonGroup"; +import { Tooltip, TooltipTrigger } from "./Tooltip"; + +export default { + title: "UiComponents/ToggleButtonGroup", + component: ToggleButtonGroup, + parameters: { layout: "centered" }, +} as Meta; + +type Story = StoryObj; + +const sizes = ["sm", "md", "lg"] as const; + +const periods = [ + { id: "day", label: "Day", description: "One row per day" }, + { id: "week", label: "Week", description: "One row per week" }, + { id: "month", label: "Month", description: "One row per month" }, +]; + +const detailLevels = [ + { id: "summary", icon: faCircle, label: "Summary" }, + { id: "detail", icon: faCircleDot, label: "Detail" }, + { id: "full", icon: faBullseye, label: "Everything" }, +]; + +/** one connected control, exactly one segment selected, in every size */ +export const Segmented: Story = { + render: () => ( +
+ {sizes.map((size) => ( + + {periods.map(({ id, label }) => ( + + {label} + + ))} + + ))} +
+ ), +}; + +/** each segment explains itself in a tooltip */ +export const SegmentedWithTooltips: Story = { + render: () => ( + + {periods.map(({ id, label, description }) => ( + + {label} + {description} + + ))} + + ), +}; + +/** the segments stack when the group is vertical */ +export const SegmentedVertical: Story = { + render: () => ( + + {periods.map(({ id, label }) => ( + + {label} + + ))} + + ), +}; + +/** separate buttons, any number of them on: a filter */ +export const MultipleSelection: Story = { + render: () => ( + + + + money + + + + concepts + + + + other + + + ), +}; + +/** a vertical toolbar of icon toggles, one of them on, tooltips to the side */ +export const VerticalIcons: Story = { + render: () => ( + + {detailLevels.map(({ id, icon, label }) => ( + + + + + {label} + + ))} + + ), +}; diff --git a/frontend/src/js/ui-components/ToggleButtonGroup.tsx b/frontend/src/js/ui-components/ToggleButtonGroup.tsx index 8203dbc038..ea5fac9003 100644 --- a/frontend/src/js/ui-components/ToggleButtonGroup.tsx +++ b/frontend/src/js/ui-components/ToggleButtonGroup.tsx @@ -1,71 +1,74 @@ -import { Focusable } from "react-aria-components"; +import { createContext, type ReactNode, useContext } from "react"; +import { + ToggleButtonGroup as RacToggleButtonGroup, + type ToggleButtonGroupProps as RacToggleButtonGroupProps, +} from "react-aria-components"; import { tv } from "tailwind-variants"; -import { Tooltip, TooltipTrigger } from "./Tooltip"; - -const root = tv({ base: ["m-0", "flex flex-wrap items-center"] }); - -const option = tv({ - base: [ - "inline-block", - "px-2 py-1", - "-ml-px mb-[2px]", - "cursor-pointer", - "border border-gray-500", - "text-xs", - "transition-[color,background-color] duration-100", - ], +const groupStyle = tv({ + base: "flex items-center", variants: { - active: { - true: ["text-gray-800", "bg-white hover:bg-white"], - false: ["text-gray-500", "bg-gray-50 hover:bg-bg-50"], + orientation: { + horizontal: "flex-row flex-wrap gap-1", + vertical: "flex-col gap-1", }, - isFirst: { true: ["ml-0", "rounded-l-[2px]"] }, - isLast: { true: "rounded-r-[2px]" }, + // the buttons join into one control and share their borders + segmented: { true: "inline-flex flex-nowrap gap-0" }, }, + defaultVariants: { orientation: "horizontal" }, }); -interface OptionsT { - label: string; - value: string; - description?: string; -} +type Orientation = "horizontal" | "vertical"; +type Size = "sm" | "md" | "lg"; -const ToggleButtonGroup = ({ - options, - value: inputValue, - onChange, - className, -}: { - className?: string; - options: OptionsT[]; - value: string; - onChange: (value: string) => void; -}) => { - return ( -
- {options.map(({ value, label, description }, i) => ( - - - - - {description} - - ))} -
- ); -}; +/** what a ToggleButton needs to know about the group it sits in */ +export const ToggleButtonGroupContext = createContext<{ + segmented: boolean; + orientation: Orientation; + size?: Size; +} | null>(null); + +export const useToggleButtonGroup = () => useContext(ToggleButtonGroupContext); + +export interface ToggleButtonGroupProps + extends Omit { + children?: ReactNode; + orientation?: Orientation; + /** one connected control: the buttons share their borders, a segmented control */ + segmented?: boolean; + /** the size of every button in the group */ + size?: Size; +} -export default ToggleButtonGroup; +/** + * A group of ToggleButtons with one selection, react-aria's ToggleButtonGroup + * underneath: `selectionMode` single or multiple, `selectedKeys` / + * `onSelectionChange` (or `defaultSelectedKeys`), `disallowEmptySelection`, + * and the arrow keys move along the group. Each button carries its key as `id`. + * + * …}> + * Range + * Exact + * + * + * Plain, the buttons keep their own look with a small gap; `segmented` joins + * them into one bordered control. Layout around the group belongs to the parent. + */ +export const ToggleButtonGroup = ({ + orientation = "horizontal", + segmented = false, + size, + children, + ...props +}: ToggleButtonGroupProps) => ( + + + {children} + + +);