From 89016b01d2aa8412c57bd820a384228e343bf975 Mon Sep 17 00:00:00 2001 From: Andrii Arsenin Date: Thu, 9 Apr 2026 14:29:16 +0300 Subject: [PATCH] feat(client): add Card header/footer, DescriptionList, and portal ContextMenu - add Card header prop (primary/secondary variants) and footer prop (sticks to bottom) - add Breadcrumbs to all 5 pages; remove duplicate h1 headings - add pageToolbar layout for breadcrumbs + danger button aligned right - add DescriptionList component replacing raw dl/dt/dd markup; supports truncate+tooltip - rewrite ContextMenu to render dropdown via React portal to avoid clip/overflow issues - update EnvironmentCard to use Card header (primary), footer (Timestamp), and truncated DescriptionList - fix cog button contrast, square sizing, and right-edge alignment on colored header - add border to Timestamp pill for dark-mode visibility - add id badge pill styling to environment card header --- client/.storybook/stories/Card.stories.tsx | 26 ++++++ .../stories/DescriptionList.stories.tsx | 50 ++++++++++ client/src/pages/EnvironmentDetailPage.tsx | 45 +++++---- client/src/pages/EnvironmentsPage.tsx | 72 +++++++-------- client/src/pages/Page.module.css | 19 +++- client/src/ui/Card/Card.module.css | 38 ++++++++ client/src/ui/Card/Card.tsx | 24 ++++- .../src/ui/ContextMenu/ContextMenu.module.css | 7 +- client/src/ui/ContextMenu/ContextMenu.tsx | 91 ++++++++++++------- .../DescriptionList.module.css | 39 ++++++++ .../ui/DescriptionList/DescriptionList.tsx | 41 +++++++++ client/src/ui/Timestamp/Timestamp.module.css | 1 + client/src/ui/index.ts | 3 + 13 files changed, 359 insertions(+), 97 deletions(-) create mode 100644 client/.storybook/stories/DescriptionList.stories.tsx create mode 100644 client/src/ui/DescriptionList/DescriptionList.module.css create mode 100644 client/src/ui/DescriptionList/DescriptionList.tsx diff --git a/client/.storybook/stories/Card.stories.tsx b/client/.storybook/stories/Card.stories.tsx index 927d65c..76c31d3 100644 --- a/client/.storybook/stories/Card.stories.tsx +++ b/client/.storybook/stories/Card.stories.tsx @@ -18,6 +18,7 @@ const meta: Meta = { ], argTypes: { padding: { control: 'select', options: ['sm', 'md', 'lg'] }, + headerVariant: { control: 'select', options: ['primary', 'secondary'] }, }, }; export default meta; @@ -27,6 +28,31 @@ export const Default: Story = { args: { children: 'Card content goes here.', padding: 'md' }, }; +export const WithPrimaryHeader: Story = { + args: { + header: 'Section Title', + headerVariant: 'primary', + children: 'Card content with a primary header.', + }, +}; + +export const WithSecondaryHeader: Story = { + args: { + header: 'Section Title', + headerVariant: 'secondary', + children: 'Card content with a secondary header.', + }, +}; + +export const WithFooter: Story = { + args: { + header: 'My Card', + headerVariant: 'primary', + children: 'Card body content.', + footer: 'Last updated 2 minutes ago', + }, +}; + export const ScenarioCard: Story = { render: () => ( diff --git a/client/.storybook/stories/DescriptionList.stories.tsx b/client/.storybook/stories/DescriptionList.stories.tsx new file mode 100644 index 0000000..bc8e944 --- /dev/null +++ b/client/.storybook/stories/DescriptionList.stories.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { DescriptionList } from '../../src/ui/DescriptionList/DescriptionList'; + +const meta: Meta = { + title: 'UI/DescriptionList', + component: DescriptionList, + parameters: { layout: 'centered' }, + tags: ['autodocs'], + decorators: [ + (Story) => ( +
+ +
+ ), + ], + argTypes: { + layout: { control: 'select', options: ['inline', 'compact'] }, + }, +}; +export default meta; +type Story = StoryObj; + +const urlItems = [ + { term: 'ID_URL', detail: 'https://id.example.com/' }, + { term: 'CABINET_URL', detail: 'https://cabinet.example.com/messages' }, + { term: 'ADMIN_URL', detail: 'https://admin.example.com/' }, +]; + +export const Inline: Story = { + args: { items: urlItems, layout: 'inline' }, +}; + +export const Compact: Story = { + args: { items: urlItems, layout: 'compact' }, +}; + +export const WithLinks: Story = { + args: { + layout: 'inline', + items: urlItems.map(({ term, detail }) => ({ + term, + detail: ( + + {detail} + + ), + })), + }, +}; diff --git a/client/src/pages/EnvironmentDetailPage.tsx b/client/src/pages/EnvironmentDetailPage.tsx index b36da30..d85cca2 100644 --- a/client/src/pages/EnvironmentDetailPage.tsx +++ b/client/src/pages/EnvironmentDetailPage.tsx @@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next'; import { Trash2 } from 'lucide-react'; import { environments } from '../api'; import type { Environment } from '../api'; -import { Breadcrumbs, Button, Card, Timestamp } from '../ui'; +import { Breadcrumbs, Button, Card, DescriptionList, Timestamp } from '../ui'; import styles from './Page.module.css'; export function EnvironmentDetailPage() { @@ -54,12 +54,19 @@ export function EnvironmentDetailPage() { {env && ( <>
- {t('environments.field_id')} - {env.id} - {t('environments.field_created')} - - {t('environments.field_updated')} - + , + }, + { + term: t('environments.field_updated'), + detail: , + }, + ]} + />

{t('environments.section_urls')}

@@ -67,20 +74,18 @@ export function EnvironmentDetailPage() { {Object.entries(env.urls).filter(([, v]) => v).length === 0 ? (

{t('environments.no_urls')}

) : ( -
- {Object.entries(env.urls) + v) - .map(([key, value]) => ( -
-
{key}
-
- - {value} - -
-
- ))} -
+ .map(([key, value]) => ({ + term: key, + detail: ( + + {value} + + ), + }))} + /> )}
diff --git a/client/src/pages/EnvironmentsPage.tsx b/client/src/pages/EnvironmentsPage.tsx index 51bd141..909da79 100644 --- a/client/src/pages/EnvironmentsPage.tsx +++ b/client/src/pages/EnvironmentsPage.tsx @@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next'; import { Settings, ExternalLink, Trash2 } from 'lucide-react'; import { environments } from '../api'; import type { Environment } from '../api'; -import { Breadcrumbs, Button, Card, ContextMenu, Timestamp } from '../ui'; +import { Breadcrumbs, Button, Card, ContextMenu, DescriptionList, Timestamp } from '../ui'; import styles from './Page.module.css'; function EnvironmentCard({ env, onDelete }: { env: Environment; onDelete: (id: number) => void }) { @@ -12,43 +12,43 @@ function EnvironmentCard({ env, onDelete }: { env: Environment; onDelete: (id: n const navigate = useNavigate(); const urlEntries = Object.entries(env.urls).filter(([, v]) => v); + const header = ( +
+ #{env.id} + {env.name} + + + + } + items={[ + { + label: t('environments.action_view'), + icon: , + onClick: () => navigate(`/environments/${env.id}`), + }, + { + label: t('environments.action_delete'), + icon: , + variant: 'danger', + onClick: () => onDelete(env.id), + }, + ]} + /> +
+ ); + return ( - -
- #{env.id} - {env.name} - - - - - } - items={[ - { - label: t('environments.action_view'), - icon: , - onClick: () => navigate(`/environments/${env.id}`), - }, - { - label: t('environments.action_delete'), - icon: , - variant: 'danger', - onClick: () => onDelete(env.id), - }, - ]} - /> -
+ } + > {urlEntries.length > 0 && ( -
- {urlEntries.map(([key, value]) => ( -
-
{key}
-
{value}
-
- ))} -
+ ({ term: key, detail: value }))} /> )} {urlEntries.length === 0 && (

{t('environments.no_urls')}

diff --git a/client/src/pages/Page.module.css b/client/src/pages/Page.module.css index 1504e29..4673833 100644 --- a/client/src/pages/Page.module.css +++ b/client/src/pages/Page.module.css @@ -53,8 +53,25 @@ .envCardId { font-size: var(--font-size-xs); - color: var(--color-text-muted); + font-weight: 700; + color: inherit; flex-shrink: 0; + background: rgba(0, 0, 0, 0.22); + border: 1px solid rgba(0, 0, 0, 0.35); + border-radius: 9999px; + padding: 1px var(--space-2); + line-height: 1.6; +} + +.envCardHeader button { + color: inherit; + padding: var(--space-1); + aspect-ratio: 1; + margin-right: calc(-1 * var(--space-1)); +} + +.envCardHeader button:hover { + background: color-mix(in srgb, currentColor 15%, transparent) !important; } .envCardName { diff --git a/client/src/ui/Card/Card.module.css b/client/src/ui/Card/Card.module.css index 27a40b6..a1a0756 100644 --- a/client/src/ui/Card/Card.module.css +++ b/client/src/ui/Card/Card.module.css @@ -6,6 +6,44 @@ font-family: var(--font-family); } +.header { + padding: var(--space-3) var(--space-6); + font-weight: 600; + font-size: var(--font-size-sm); + border-radius: calc(var(--radius-lg) - var(--border-width)) calc(var(--radius-lg) - var(--border-width)) 0 0; +} + +.header-primary { + background: var(--color-primary); + color: var(--color-primary-fg); + border-bottom: var(--border-width) solid var(--color-primary-active); +} + +.header-secondary { + background: var(--color-secondary); + color: var(--color-secondary-fg); + border-bottom: var(--border-width) solid var(--color-secondary-border); +} + +.withFooter { + display: flex; + flex-direction: column; +} + +.withFooter .body { + flex: 1; +} + +.body { } + +.footer { + padding: var(--space-2) var(--space-6); + border-top: var(--border-width) solid var(--color-border); + font-size: var(--font-size-xs); + color: var(--color-text-muted); + background: var(--color-bg-subtle); +} + .sm { padding: var(--space-3) var(--space-4); } .md { padding: var(--space-4) var(--space-6); } .lg { padding: var(--space-6) var(--space-8); } diff --git a/client/src/ui/Card/Card.tsx b/client/src/ui/Card/Card.tsx index 99c9f93..f954857 100644 --- a/client/src/ui/Card/Card.tsx +++ b/client/src/ui/Card/Card.tsx @@ -5,12 +5,30 @@ export interface CardProps { children: React.ReactNode; className?: string; padding?: 'sm' | 'md' | 'lg'; + header?: React.ReactNode; + headerVariant?: 'primary' | 'secondary'; + footer?: React.ReactNode; } -export function Card({ children, className, padding = 'md' }: CardProps) { +export function Card({ + children, + className, + padding = 'md', + header, + headerVariant = 'primary', + footer, +}: CardProps) { return ( -
- {children} +
+ {header !== undefined && ( +
{header}
+ )} +
{children}
+ {footer !== undefined &&
{footer}
}
); } diff --git a/client/src/ui/ContextMenu/ContextMenu.module.css b/client/src/ui/ContextMenu/ContextMenu.module.css index 7592c83..e6e908e 100644 --- a/client/src/ui/ContextMenu/ContextMenu.module.css +++ b/client/src/ui/ContextMenu/ContextMenu.module.css @@ -8,9 +8,7 @@ } .menu { - position: absolute; - top: calc(100% + var(--space-1)); - z-index: 100; + z-index: 9999; min-width: 160px; margin: 0; padding: var(--space-1) 0; @@ -21,9 +19,6 @@ box-shadow: var(--shadow-md); } -.alignRight { right: 0; } -.alignLeft { left: 0; } - .item { display: flex; align-items: center; diff --git a/client/src/ui/ContextMenu/ContextMenu.tsx b/client/src/ui/ContextMenu/ContextMenu.tsx index 3a0aceb..79ffb64 100644 --- a/client/src/ui/ContextMenu/ContextMenu.tsx +++ b/client/src/ui/ContextMenu/ContextMenu.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; import styles from './ContextMenu.module.css'; export interface ContextMenuItem { @@ -17,12 +18,20 @@ export interface ContextMenuProps { export function ContextMenu({ items, trigger, align = 'right' }: ContextMenuProps) { const [open, setOpen] = useState(false); - const rootRef = useRef(null); + const [coords, setCoords] = useState({ top: 0, left: 0, right: 0 }); + const triggerRef = useRef(null); + const menuRef = useRef(null); useEffect(() => { if (!open) return; function handleOutside(e: MouseEvent) { - if (rootRef.current && !rootRef.current.contains(e.target as Node)) { + const target = e.target as Node; + if ( + triggerRef.current && + !triggerRef.current.contains(target) && + menuRef.current && + !menuRef.current.contains(target) + ) { setOpen(false); } } @@ -39,44 +48,64 @@ export function ContextMenu({ items, trigger, align = 'right' }: ContextMenuProp return () => document.removeEventListener('keydown', handleKey); }, [open]); + function openMenu() { + if (!triggerRef.current) return; + const rect = triggerRef.current.getBoundingClientRect(); + setCoords({ + top: rect.bottom + window.scrollY + 4, + left: rect.left + window.scrollX, + right: window.innerWidth - rect.right - window.scrollX, + }); + setOpen((v) => !v); + } + + const menuStyle = + align === 'right' + ? { top: coords.top, right: coords.right } + : { top: coords.top, left: coords.left }; + return ( -
+
{ e.stopPropagation(); - setOpen((v) => !v); + openMenu(); }} > {trigger}
- {open && ( -
    - {items.map((item, i) => ( -
  • - -
  • - ))} -
- )} + {open && + createPortal( +
    + {items.map((item, i) => ( +
  • + +
  • + ))} +
, + document.body, + )}
); } diff --git a/client/src/ui/DescriptionList/DescriptionList.module.css b/client/src/ui/DescriptionList/DescriptionList.module.css new file mode 100644 index 0000000..265ebf4 --- /dev/null +++ b/client/src/ui/DescriptionList/DescriptionList.module.css @@ -0,0 +1,39 @@ +.list { + margin: 0; + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.row { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0; + font-size: var(--font-size-sm); +} + +.term { + color: var(--color-text-muted); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + font-size: var(--font-size-xs); + white-space: nowrap; + flex-shrink: 0; +} + +.detail { + margin: 0; + color: var(--color-text); + word-break: break-all; +} + +.truncate { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + word-break: normal; + max-width: 100%; +} + diff --git a/client/src/ui/DescriptionList/DescriptionList.tsx b/client/src/ui/DescriptionList/DescriptionList.tsx new file mode 100644 index 0000000..1cf8c31 --- /dev/null +++ b/client/src/ui/DescriptionList/DescriptionList.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import styles from './DescriptionList.module.css'; + +function extractText(node: React.ReactNode): string { + if (typeof node === 'string' || typeof node === 'number') return String(node); + if (Array.isArray(node)) return node.map(extractText).join(''); + if (React.isValidElement(node)) return extractText(node.props.children); + return ''; +} + +export interface DescriptionListItem { + term: React.ReactNode; + detail: React.ReactNode; +} + +export interface DescriptionListProps { + items: DescriptionListItem[]; + /** 'compact' stacks term above detail; 'inline' places them side by side (default) */ + layout?: 'inline' | 'compact'; + /** Truncate detail values with ellipsis; full value shown in a tooltip on hover */ + truncate?: boolean; + className?: string; +} + +export function DescriptionList({ items, layout = 'inline', truncate, className }: DescriptionListProps) { + return ( +
+ {items.map((item, i) => ( +
+
{item.term}
+
+ {item.detail} +
+
+ ))} +
+ ); +} diff --git a/client/src/ui/Timestamp/Timestamp.module.css b/client/src/ui/Timestamp/Timestamp.module.css index 562c0bf..2a38fe6 100644 --- a/client/src/ui/Timestamp/Timestamp.module.css +++ b/client/src/ui/Timestamp/Timestamp.module.css @@ -10,6 +10,7 @@ line-height: 1.6; background: var(--color-neutral-bg); color: var(--color-neutral-fg); + border: var(--border-width) solid var(--color-border); cursor: default; } diff --git a/client/src/ui/index.ts b/client/src/ui/index.ts index 75e107d..8167ac2 100644 --- a/client/src/ui/index.ts +++ b/client/src/ui/index.ts @@ -30,5 +30,8 @@ export type { PaginationProps } from './Pagination/Pagination'; export { ContextMenu } from './ContextMenu/ContextMenu'; export type { ContextMenuProps, ContextMenuItem } from './ContextMenu/ContextMenu'; +export { DescriptionList } from './DescriptionList/DescriptionList'; +export type { DescriptionListProps, DescriptionListItem } from './DescriptionList/DescriptionList'; + export { Table } from './Table/Table'; export type { TableProps, TableColumn } from './Table/Table';