From 635fc2140877037cd8e94f7bb7158de1127bf960 Mon Sep 17 00:00:00 2001 From: Paulo Ferreira Jorge Date: Tue, 23 Jun 2026 17:14:19 +0100 Subject: [PATCH 1/2] Implement Chart Style Options Menu --- .../components/elements/card/base.module.scss | 17 -- .../web/src/components/elements/card/base.tsx | 17 +- .../elements/card/chart/chart.module.scss | 19 ++- .../components/elements/card/chart/chart.tsx | 148 ++++++++++++++---- .../card/chart/menu_chart_options.module.scss | 73 +++++++++ .../card/chart/menu_chart_options.tsx | 127 +++++++++++++++ .../elements/card/footer.module.scss | 4 + .../src/components/elements/card/footer.tsx | 10 ++ .../web/src/components/elements/card/index.ts | 2 + .../components/elements/card/text.module.scss | 7 + .../web/src/components/elements/card/text.tsx | 76 ++++++++- .../primitives/icons/bar_chart_horizontal.tsx | 18 +++ .../{bar_chart.tsx => bar_chart_outlined.tsx} | 2 +- .../primitives/icons/bar_chart_vertical.tsx | 18 +++ .../primitives/icons/line_graph_double.tsx | 18 +++ .../{line_graph.tsx => line_graph_single.tsx} | 2 +- .../in_front_of_canvas/selection.tsx | 4 +- .../components/scopes/atlas/shapes/card.tsx | 106 +++---------- dataweaver/packages/tokens/src/colors.json | 1 + 19 files changed, 511 insertions(+), 158 deletions(-) create mode 100644 dataweaver/apps/web/src/components/elements/card/chart/menu_chart_options.module.scss create mode 100644 dataweaver/apps/web/src/components/elements/card/chart/menu_chart_options.tsx create mode 100644 dataweaver/apps/web/src/components/elements/card/footer.module.scss create mode 100644 dataweaver/apps/web/src/components/elements/card/footer.tsx create mode 100644 dataweaver/apps/web/src/components/primitives/icons/bar_chart_horizontal.tsx rename dataweaver/apps/web/src/components/primitives/icons/{bar_chart.tsx => bar_chart_outlined.tsx} (89%) create mode 100644 dataweaver/apps/web/src/components/primitives/icons/bar_chart_vertical.tsx create mode 100644 dataweaver/apps/web/src/components/primitives/icons/line_graph_double.tsx rename dataweaver/apps/web/src/components/primitives/icons/{line_graph.tsx => line_graph_single.tsx} (83%) diff --git a/dataweaver/apps/web/src/components/elements/card/base.module.scss b/dataweaver/apps/web/src/components/elements/card/base.module.scss index 4bf8ec78..0fa77bde 100644 --- a/dataweaver/apps/web/src/components/elements/card/base.module.scss +++ b/dataweaver/apps/web/src/components/elements/card/base.module.scss @@ -55,20 +55,3 @@ transform: translateY(var(--actions-height)); } } - -.content { - display: flex; - flex-shrink: 0; - flex-direction: column; - padding: 28px; -} - -.footer { - flex-shrink: 0; - padding: 0 28px 18px; - - [data-loading="true"] & { - visibility: hidden; - pointer-events: none; - } -} diff --git a/dataweaver/apps/web/src/components/elements/card/base.tsx b/dataweaver/apps/web/src/components/elements/card/base.tsx index e6130c8f..86549a60 100644 --- a/dataweaver/apps/web/src/components/elements/card/base.tsx +++ b/dataweaver/apps/web/src/components/elements/card/base.tsx @@ -26,22 +26,21 @@ interface CardAction { /** @default false */ isDisabled?: boolean; + + /** @default false */ + isActive?: boolean; } interface CardProps extends CardState { actions: CardAction[]; - content: ReactNode; - - /** **Note**: This isn't shown while `isLoading`. */ - footer?: ReactNode; + children: ReactNode; } export const CardBase = ({ isLoading, selection, actions, - content, - footer, + children, }: CardProps) => { const getCachedCanScroll = useCachedResizeValues((element: HTMLElement) => { return element.scrollHeight > element.clientHeight; @@ -62,6 +61,7 @@ export const CardBase = ({ variant="flat" tone="card-action" aria-label={action.label} + aria-pressed={action.isActive} // Prevent tldraw from triggering canvas gestures (e.g. dragging) onPointerDown={(event) => event.stopPropagation()} onClick={action.onClick} @@ -80,10 +80,7 @@ export const CardBase = ({ } }} > -
{content}
-
- {footer} -
+ {children} ); diff --git a/dataweaver/apps/web/src/components/elements/card/chart/chart.module.scss b/dataweaver/apps/web/src/components/elements/card/chart/chart.module.scss index 75168115..ce785ddb 100644 --- a/dataweaver/apps/web/src/components/elements/card/chart/chart.module.scss +++ b/dataweaver/apps/web/src/components/elements/card/chart/chart.module.scss @@ -1,15 +1,22 @@ +.container { + display: flex; + flex-shrink: 0; + flex-direction: column; + padding: 28px; +} + .header-container { display: flex; flex-direction: column; row-gap: 8px; margin-bottom: 16px; color: rgb(var(--color-card-content)); -} -.title { - @include type-title; -} + .title { + @include type-title; + } -.description { - @include type-body-medium; + .description { + @include type-body-medium; + } } diff --git a/dataweaver/apps/web/src/components/elements/card/chart/chart.tsx b/dataweaver/apps/web/src/components/elements/card/chart/chart.tsx index 03f80ad2..fe09f50c 100644 --- a/dataweaver/apps/web/src/components/elements/card/chart/chart.tsx +++ b/dataweaver/apps/web/src/components/elements/card/chart/chart.tsx @@ -1,13 +1,25 @@ 'use client'; +import { AnimatePresence } from 'motion/react'; +import { useState } from 'react'; +import { type TLShapeId, useEditor } from 'tldraw'; +import { Button } from '~/components/elements/button'; +import { Card } from '~/components/elements/card'; import type { CardState } from '~/components/elements/card/base'; import { Skeleton } from '~/components/elements/skeleton'; -import { IconLineGraph } from '~/components/primitives/icons/line_graph'; +import { IconBarChartOutlined } from '~/components/primitives/icons/bar_chart_outlined'; +import { IconDelete } from '~/components/primitives/icons/delete'; +import { IconExport } from '~/components/primitives/icons/export'; +import { IconLineGraphSingle } from '~/components/primitives/icons/line_graph_single'; +import { IconPencil } from '~/components/primitives/icons/pencil'; import { IconTable } from '~/components/primitives/icons/table'; +import { useExportActions } from '~/components/scopes/atlas/export_provider'; +import { useQueryActions } from '~/components/scopes/atlas/query_provider'; import s from './chart.module.scss'; import { ConditionalTabs } from './conditional_tabs'; import { DataChartLine } from './data_chart_line'; import { DataTable } from './data_table'; +import { type ChartStyle, MenuChartOptions } from './menu_chart_options'; export interface ChartDatum { year: number; @@ -18,9 +30,11 @@ export interface ChartDatum { const CHART_WIDTH = 356; const CHART_HEIGHT = 200; -export interface CardChartProps extends Pick { +export interface CardChartProps extends CardState { + id: TLShapeId; title?: string; description?: string; + followUp?: string; // TODO: Atm data rendered within the card is very specific to the emissions // dataset. Let's make it more generic once we have real data to work with @@ -28,44 +42,110 @@ export interface CardChartProps extends Pick { } export const CardChart = ({ + id, isLoading, - data, + selection, title, description, + followUp, + data, }: CardChartProps) => { + const editor = useEditor(); + + const { open: openExport } = useExportActions(); + const { runPrompt } = useQueryActions(); + + // TODO: Support the different chart styles (for now we always show bar chart) + const [selectedStyle, setSelectedStyle] = + useState('bar-vertical'); + const [isStyleMenuOpen, setIsStyleMenuOpen] = useState(false); + return ( - <> - {(title || description) && ( -
- {title &&

{title}

} - {description &&

{description}

} -
- )} + setIsStyleMenuOpen((isOpen) => !isOpen), + }, + { + icon: IconExport, + label: 'Export', + isDisabled: isLoading, + onClick: () => { + editor.select(id); + openExport(); + }, + }, + { + icon: IconDelete, + label: 'Delete', + onClick: () => editor.deleteShapes([id]), + }, + ]} + > +
+ {(title || description) && ( +
+ {title &&

{title}

} + {description &&

{description}

} +
+ )} - {isLoading || !data ? ( - - ) : ( - - ), - }, - { - icon: IconTable, - label: 'Table', - children: , - }, - ]} - /> + {isLoading || !data ? ( + + ) : ( + + ), + }, + { + icon: IconTable, + label: 'Table', + children: , + }, + ]} + /> + )} +
+ + {followUp && !isLoading && ( + + + )} - + + + {isStyleMenuOpen && ( + setIsStyleMenuOpen(false)} + /> + )} + +
); }; diff --git a/dataweaver/apps/web/src/components/elements/card/chart/menu_chart_options.module.scss b/dataweaver/apps/web/src/components/elements/card/chart/menu_chart_options.module.scss new file mode 100644 index 00000000..ce5ab206 --- /dev/null +++ b/dataweaver/apps/web/src/components/elements/card/chart/menu_chart_options.module.scss @@ -0,0 +1,73 @@ +.container { + position: absolute; + inset: 0; + z-index: $z-index-above-content; + display: grid; + background: rgb(var(--color-card-overlay-backdrop) / 30%); +} + +.backdrop { + z-index: $z-index-behind-content; + grid-area: 1 / 1; + height: 100%; + background: rgb(var(--color-card-accent-subtle) / 30%); +} + +.content-container { + display: flex; + flex-direction: column; + grid-area: 1 / 1; + place-self: center center; + width: 300px; + max-width: 100%; + background: rgb(var(--color-card-surface)); + border-radius: var(--corner-size); + box-shadow: var(--shadow-elevated); +} + +.title { + @include type-title; + + padding: 16px 16px 10px; + color: rgb(var(--color-card-content)); +} + +.options-container { + display: flex; + flex-direction: column; + row-gap: 2px; + padding-inline: 12px; + + .option-container { + column-gap: 12px; + align-items: center; + height: 40px; + padding-inline: 12px; + border-radius: var(--border-radius-small); + transition: background 0.2s $ease-linear; + + @include hover { + background: rgb(var(--color-card-accent-subtle)); + } + + .option-icon { + flex-shrink: 0; + width: 24px; + height: 24px; + color: rgb(var(--color-card-content)); + } + + .option-label { + @include type-body-medium; + + color: rgb(var(--color-card-content)); + } + } +} + +.actions-container { + display: flex; + gap: 8px; + justify-content: flex-end; + padding: 32px 18px 20px 0; +} diff --git a/dataweaver/apps/web/src/components/elements/card/chart/menu_chart_options.tsx b/dataweaver/apps/web/src/components/elements/card/chart/menu_chart_options.tsx new file mode 100644 index 00000000..84f3aaea --- /dev/null +++ b/dataweaver/apps/web/src/components/elements/card/chart/menu_chart_options.tsx @@ -0,0 +1,127 @@ +'use client'; + +import { EASE_LINEAR } from '@package/tokens/ts'; +import { m } from 'motion/react'; +import type { ComponentPropsWithRef, ComponentType } from 'react'; +import { useRef, useState } from 'react'; +import { Button } from '~/components/elements/button'; +import { Radio } from '~/components/elements/radio'; +import { IconBarChartHorizontal } from '~/components/primitives/icons/bar_chart_horizontal'; +import { IconBarChartVertical } from '~/components/primitives/icons/bar_chart_vertical'; +import { IconLineGraphDouble } from '~/components/primitives/icons/line_graph_double'; +import { ScreenReaderOnly } from '~/components/primitives/screen_reader'; +import { useFocusTrap } from '~/hooks/use_focus_trap'; +import { useKeydown } from '~/hooks/use_keydown'; +import s from './menu_chart_options.module.scss'; + +export type ChartStyle = 'bar-vertical' | 'bar-horizontal' | 'line'; + +interface ChartStyleOption { + key: ChartStyle; + label: string; + icon: ComponentType>; +} + +const CHART_STYLE_OPTIONS: ChartStyleOption[] = [ + { + key: 'bar-vertical', + label: 'Bar chart vertical', + icon: IconBarChartVertical, + }, + { + key: 'bar-horizontal', + label: 'Bar chart horizontal', + icon: IconBarChartHorizontal, + }, + { + key: 'line', + label: 'Line chart', + icon: IconLineGraphDouble, + }, +] as const; + +interface MenuChartOptionsProps { + value: ChartStyle; + onConfirmSelectionChange: (style: ChartStyle) => void; + onClose: () => void; +} + +export const MenuChartOptions = ({ + value, + onClose, + onConfirmSelectionChange, +}: MenuChartOptionsProps) => { + const containerRef = useRef(null); + + const [selectedValue, setSelectedValue] = useState(value); + + useKeydown('Escape', onClose); + + // TODO: For now this doesn't seem to really work due to TLDraw consuming + // tab events. Review focus trap implementation once we review how TLDraw + // handles focus and keyboard events in general, and adjust as needed + useFocusTrap(containerRef); + + return ( + event.stopPropagation()} + > + {/** biome-ignore lint/a11y/useKeyWithClickEvents: This is a backdrop that closes the menu on click. */} + + +
+

Chart options

+ +
+ + Choose a chart style + + +
    + {CHART_STYLE_OPTIONS.map((option) => ( +
  • + setSelectedValue(option.key)} + > + + {option.label} + +
  • + ))} +
+
+ +
+ + + +
+
+
+ ); +}; diff --git a/dataweaver/apps/web/src/components/elements/card/footer.module.scss b/dataweaver/apps/web/src/components/elements/card/footer.module.scss new file mode 100644 index 00000000..f49860e9 --- /dev/null +++ b/dataweaver/apps/web/src/components/elements/card/footer.module.scss @@ -0,0 +1,4 @@ +.container { + flex-shrink: 0; + padding: 0 28px 18px; +} diff --git a/dataweaver/apps/web/src/components/elements/card/footer.tsx b/dataweaver/apps/web/src/components/elements/card/footer.tsx new file mode 100644 index 00000000..9778af4f --- /dev/null +++ b/dataweaver/apps/web/src/components/elements/card/footer.tsx @@ -0,0 +1,10 @@ +import type { ReactNode } from 'react'; +import s from './footer.module.scss'; + +interface CardFooterProps { + children: ReactNode; +} + +export const CardFooter = ({ children }: CardFooterProps) => { + return
{children}
; +}; diff --git a/dataweaver/apps/web/src/components/elements/card/index.ts b/dataweaver/apps/web/src/components/elements/card/index.ts index f9c7abc8..03a0db5a 100644 --- a/dataweaver/apps/web/src/components/elements/card/index.ts +++ b/dataweaver/apps/web/src/components/elements/card/index.ts @@ -1,9 +1,11 @@ import { CardBase } from './base'; import { CardChart } from './chart/chart'; +import { CardFooter } from './footer'; import { CardText } from './text'; export const Card = { Base: CardBase, Text: CardText, Chart: CardChart, + Footer: CardFooter, } as const; diff --git a/dataweaver/apps/web/src/components/elements/card/text.module.scss b/dataweaver/apps/web/src/components/elements/card/text.module.scss index 99067a22..779814c6 100644 --- a/dataweaver/apps/web/src/components/elements/card/text.module.scss +++ b/dataweaver/apps/web/src/components/elements/card/text.module.scss @@ -1,3 +1,10 @@ +.container { + display: flex; + flex-shrink: 0; + flex-direction: column; + padding: 28px; +} + .title { @include type-title; diff --git a/dataweaver/apps/web/src/components/elements/card/text.tsx b/dataweaver/apps/web/src/components/elements/card/text.tsx index 765c5b5f..493f8f64 100644 --- a/dataweaver/apps/web/src/components/elements/card/text.tsx +++ b/dataweaver/apps/web/src/components/elements/card/text.tsx @@ -1,18 +1,82 @@ +'use client'; + +import { type TLShapeId, useEditor } from 'tldraw'; +import { Button } from '~/components/elements/button'; +import { Card } from '~/components/elements/card'; import type { CardState } from '~/components/elements/card/base'; import { Skeleton } from '~/components/elements/skeleton'; +import { IconDelete } from '~/components/primitives/icons/delete'; +import { IconExport } from '~/components/primitives/icons/export'; +import { IconPencil } from '~/components/primitives/icons/pencil'; +import { useExportActions } from '~/components/scopes/atlas/export_provider'; +import { useQueryActions } from '~/components/scopes/atlas/query_provider'; import s from './text.module.scss'; -export interface CardTextProps extends Pick { +export interface CardTextProps extends CardState { + id: TLShapeId; title?: string; body?: string; + followUp?: string; } -export const CardText = ({ title, body, isLoading }: CardTextProps) => { +export const CardText = ({ + id, + isLoading, + selection, + title, + body, + followUp, +}: CardTextProps) => { + const editor = useEditor(); + + const { open: openExport } = useExportActions(); + const { runPrompt } = useQueryActions(); + return ( - <> - {title &&

{title}

} + { + editor.select(id); + openExport(); + }, + }, + { + icon: IconDelete, + label: 'Delete', + onClick: () => editor.deleteShapes([id]), + }, + ]} + > +
+ {title &&

{title}

} + + {isLoading ? ( + + ) : ( + body &&
{body}
+ )} +
- {isLoading ? : body &&
{body}
} - + {followUp && !isLoading && ( + + + + )} +
); }; diff --git a/dataweaver/apps/web/src/components/primitives/icons/bar_chart_horizontal.tsx b/dataweaver/apps/web/src/components/primitives/icons/bar_chart_horizontal.tsx new file mode 100644 index 00000000..25595bf0 --- /dev/null +++ b/dataweaver/apps/web/src/components/primitives/icons/bar_chart_horizontal.tsx @@ -0,0 +1,18 @@ +import type { ComponentPropsWithRef } from 'react'; + +export const IconBarChartHorizontal = (props: ComponentPropsWithRef<'svg'>) => { + return ( + + ); +}; diff --git a/dataweaver/apps/web/src/components/primitives/icons/bar_chart.tsx b/dataweaver/apps/web/src/components/primitives/icons/bar_chart_outlined.tsx similarity index 89% rename from dataweaver/apps/web/src/components/primitives/icons/bar_chart.tsx rename to dataweaver/apps/web/src/components/primitives/icons/bar_chart_outlined.tsx index 02606bec..7544cdef 100644 --- a/dataweaver/apps/web/src/components/primitives/icons/bar_chart.tsx +++ b/dataweaver/apps/web/src/components/primitives/icons/bar_chart_outlined.tsx @@ -1,6 +1,6 @@ import type { ComponentPropsWithRef } from 'react'; -export const IconBarChart = (props: ComponentPropsWithRef<'svg'>) => { +export const IconBarChartOutlined = (props: ComponentPropsWithRef<'svg'>) => { return ( ) => { + return ( + + ); +}; diff --git a/dataweaver/apps/web/src/components/primitives/icons/line_graph_double.tsx b/dataweaver/apps/web/src/components/primitives/icons/line_graph_double.tsx new file mode 100644 index 00000000..00e18051 --- /dev/null +++ b/dataweaver/apps/web/src/components/primitives/icons/line_graph_double.tsx @@ -0,0 +1,18 @@ +import type { ComponentPropsWithRef } from 'react'; + +export const IconLineGraphDouble = (props: ComponentPropsWithRef<'svg'>) => { + return ( + + ); +}; diff --git a/dataweaver/apps/web/src/components/primitives/icons/line_graph.tsx b/dataweaver/apps/web/src/components/primitives/icons/line_graph_single.tsx similarity index 83% rename from dataweaver/apps/web/src/components/primitives/icons/line_graph.tsx rename to dataweaver/apps/web/src/components/primitives/icons/line_graph_single.tsx index a51a7f70..cef2163d 100644 --- a/dataweaver/apps/web/src/components/primitives/icons/line_graph.tsx +++ b/dataweaver/apps/web/src/components/primitives/icons/line_graph_single.tsx @@ -1,6 +1,6 @@ import type { ComponentPropsWithRef } from 'react'; -export const IconLineGraph = (props: ComponentPropsWithRef<'svg'>) => { +export const IconLineGraphSingle = (props: ComponentPropsWithRef<'svg'>) => { return ( { aria-label="Selection actions" >

Chart options