Skip to content

Commit 98fa26b

Browse files
committed
feat: Add MapInspector enhancements with PhotoCarousel and ExpandableText
Major enhancements to Map components for rich property detail views. NEW COMPONENTS: 1. Overlay Component (Shared) - Generic overlay for images/media with configurable background, height, align - Supports presets: 'dark', 'light', 'transparent' + custom colors - Reusable across SummaryCard, PhotoCarousel, and other components 2. PhotoCarousel Component - Embla-based photo carousel with swipe support - Navigation dots and prev/next arrows - topOverlay support for branding (PhotoCarousel.Overlay helper) - Configurable aspect ratio (5:4 default for properties) - Loading/error/empty states 3. ExpandableText Component - Line-clamp truncation with configurable maxLines - "View more" / "View less" toggle (inline with text) - Auto-detects if truncation needed - Controlled/uncontrolled modes ENHANCEMENTS: 4. MapPlaceCard (renamed from LocationCard) - Added variant prop ('carousel' | 'list') - List variant: borderless, rounded on hover/select, smooth transitions - Better naming (MapPlaceCard fits Map component family) 5. MapSidebar Refactoring - Now uses MapPlaceCard component (removed duplicate code) - Borderless cards with background-only selection - Removed dividers between cards - ~100 lines of duplicate CSS removed 6. MapInspector Enhancements - PhotoCarousel integration (shows all property images vs single) - ExpandableText for descriptions (collapsible) - Headline support (auction/EOI info) using H3/Title 3 Emph - bottomAction support (link button at bottom, no divider) - Updated LocationData type: images, topOverlay, headline, bottomAction 7. Button Component - Added 'link' variant (link-style button with underline) - Useful for secondary actions 8. SummaryCard Refactoring - Now uses shared Overlay component (DRY) - Backward compatible (SummaryCard.Overlay still works) All components follow SDK design patterns, use generic naming, and are fully reusable across different use cases.
1 parent d834190 commit 98fa26b

23 files changed

+837
-108
lines changed

packages/ui/src/components/Button/Button.module.css

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,25 @@
135135
background-color: var(--ai-color-bg-secondary);
136136
}
137137

138+
/* ========== LINK VARIANT ========== */
139+
.buttonLink {
140+
background-color: transparent;
141+
color: var(--ai-color-brand-primary);
142+
border: none;
143+
padding: 0;
144+
text-decoration: underline;
145+
font-weight: var(--ai-font-weight-medium);
146+
}
147+
148+
.buttonLink:not(:disabled):hover {
149+
opacity: 0.8;
150+
background-color: transparent;
151+
}
152+
153+
.buttonLink:not(:disabled):active {
154+
opacity: 0.6;
155+
}
156+
138157
/* ========== ICON-ONLY BUTTONS ========== */
139158
.buttonIconOnly {
140159
min-width: 44px;

packages/ui/src/components/Button/Button.module.css.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ declare const styles: {
44
readonly buttonSecondary: string;
55
readonly buttonTertiary: string;
66
readonly buttonGhost: string;
7+
readonly buttonLink: string;
78
readonly buttonIconOnly: string;
89
readonly label: string;
910
readonly leftIcon: string;

packages/ui/src/components/Button/Button.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import type { IconName } from '../../tokens/icons';
66
import type { ColorVariant } from '../../tokens/colors';
77
import styles from './Button.module.css';
88

9-
export type ButtonVariant = 'primary' | 'secondary' | 'tertiary' | 'ghost';
9+
export type ButtonVariant = 'primary' | 'secondary' | 'tertiary' | 'ghost' | 'link';
1010

1111
export interface ButtonProps extends Omit<ComponentPropsWithoutRef<'button'>, 'color'> {
1212
/**
@@ -117,6 +117,7 @@ export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>((props, r
117117
secondary: styles.buttonSecondary,
118118
tertiary: styles.buttonTertiary,
119119
ghost: styles.buttonGhost,
120+
link: styles.buttonLink,
120121
}[variant];
121122

122123
const colorClass = {

packages/ui/src/components/Card/SummaryCard.module.css

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
}
3030

3131
/* Flat variant - topOverlay uses Card's border-radius */
32-
.summaryCard[data-variant='flat'] .topOverlay {
32+
.summaryCard[data-variant='flat'] .overlay {
3333
border-top-left-radius: var(--ai-radius-xl);
3434
border-top-right-radius: var(--ai-radius-xl);
3535
}
@@ -444,7 +444,7 @@
444444
}
445445

446446
/* Top Overlay - Positioned inside imageWrapper, sibling to imageSection */
447-
.topOverlay {
447+
.overlay {
448448
position: absolute;
449449
top: 0;
450450
left: 0;

packages/ui/src/components/Card/SummaryCard.module.css.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ declare const styles: {
2929
readonly emptyState: string;
3030
readonly emptyTitle: string;
3131
readonly emptyMessage: string;
32-
readonly topOverlay: string;
32+
readonly overlay: string;
3333
};
3434

3535
export default styles;

packages/ui/src/components/Card/SummaryCard.tsx

Lines changed: 6 additions & 99 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { Icon } from '../Icon';
88
import type { IconName } from '../../tokens/icons';
99
import { Skeleton } from '../Skeleton';
1010
import { Alert } from '../Alert';
11+
import { Overlay, type OverlayProps } from '../Overlay';
1112
import styles from './SummaryCard.module.css';
1213

1314
export interface SummaryCardImage {
@@ -278,103 +279,9 @@ const normalizeImage = (image: string | SummaryCardImage): SummaryCardImage => {
278279

279280
/**
280281
* Props for SummaryCard.Overlay helper component
282+
* @deprecated Use OverlayProps from shared Overlay component
281283
*/
282-
export interface SummaryCardOverlayProps {
283-
/**
284-
* Background style for the overlay
285-
* - "dark": Semi-transparent dark background (rgba(0, 0, 0, 0.6))
286-
* - "light": Semi-transparent light background (rgba(255, 255, 255, 0.8))
287-
* - "transparent": No background
288-
* - Custom string: Any valid CSS color value
289-
* @default "dark"
290-
*/
291-
background?: 'dark' | 'light' | 'transparent' | string;
292-
293-
/**
294-
* Height of the overlay
295-
* @default 40
296-
*/
297-
height?: number | string;
298-
299-
/**
300-
* Horizontal alignment of content
301-
* @default "center"
302-
*/
303-
align?: 'left' | 'center' | 'right';
304-
305-
/**
306-
* Padding inside the overlay
307-
* @default 8
308-
*/
309-
padding?: number;
310-
311-
/**
312-
* Content to render inside the overlay
313-
*/
314-
children: React.ReactNode;
315-
316-
/**
317-
* Additional CSS class name
318-
*/
319-
className?: string;
320-
}
321-
322-
/**
323-
* Helper component for creating consistently styled overlays on SummaryCard images
324-
*
325-
* @example
326-
* ```tsx
327-
* <SummaryCard
328-
* images="property.jpg"
329-
* topOverlay={
330-
* <SummaryCard.Overlay background="dark" height={40} align="center">
331-
* <img src="logo.png" alt="Logo" style={{ height: 24 }} />
332-
* </SummaryCard.Overlay>
333-
* }
334-
* />
335-
* ```
336-
*/
337-
const SummaryCardOverlay = React.forwardRef<HTMLDivElement, SummaryCardOverlayProps>(
338-
(
339-
{ background = 'dark', height = 40, align = 'center', padding = 8, children, className },
340-
ref
341-
) => {
342-
// Map background presets to CSS values
343-
const backgroundMap: Record<string, string> = {
344-
dark: 'rgba(0, 0, 0, 0.6)',
345-
light: 'rgba(255, 255, 255, 0.8)',
346-
transparent: 'transparent',
347-
};
348-
349-
const backgroundValue = backgroundMap[background] || background;
350-
351-
// Map align to justify-content values
352-
const justifyContentMap: Record<string, string> = {
353-
left: 'flex-start',
354-
center: 'center',
355-
right: 'flex-end',
356-
};
357-
358-
const justifyContent = justifyContentMap[align] || 'center';
359-
360-
return (
361-
<div
362-
ref={ref}
363-
className={cn(styles.topOverlay, className)}
364-
style={{
365-
background: backgroundValue,
366-
height: typeof height === 'number' ? `${height}px` : height,
367-
justifyContent,
368-
padding: `${padding}px`,
369-
}}
370-
>
371-
{children}
372-
</div>
373-
);
374-
}
375-
);
376-
377-
SummaryCardOverlay.displayName = 'SummaryCard.Overlay';
284+
export type SummaryCardOverlayProps = OverlayProps;
378285

379286
/**
380287
* SummaryCard component for displaying entity information with images.
@@ -876,8 +783,8 @@ SummaryCardComponent.displayName = 'SummaryCard';
876783

877784
// Create typed SummaryCard with Overlay subcomponent
878785
export const SummaryCard = SummaryCardComponent as typeof SummaryCardComponent & {
879-
Overlay: typeof SummaryCardOverlay;
786+
Overlay: typeof Overlay;
880787
};
881788

882-
// Attach Overlay component to SummaryCard
883-
SummaryCard.Overlay = SummaryCardOverlay;
789+
// Attach shared Overlay component to SummaryCard
790+
SummaryCard.Overlay = Overlay;
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
/* ExpandableText Container */
2+
.expandableText {
3+
position: relative;
4+
}
5+
6+
/* Text Content */
7+
.text {
8+
margin: 0;
9+
font-size: var(--ai-font-size-body);
10+
line-height: var(--ai-line-height-body);
11+
color: var(--ai-color-text-primary);
12+
white-space: pre-wrap;
13+
word-wrap: break-word;
14+
}
15+
16+
/* Clamped State - Use line-clamp */
17+
.clamped {
18+
display: -webkit-box;
19+
-webkit-box-orient: vertical;
20+
overflow: hidden;
21+
text-overflow: ellipsis;
22+
}
23+
24+
/* Toggle Button */
25+
.toggleButton {
26+
display: inline;
27+
margin-left: var(--ai-spacing-1);
28+
padding: 0;
29+
border: none;
30+
background: none;
31+
color: var(--ai-color-text-primary);
32+
font-size: var(--ai-font-size-body-small);
33+
font-weight: var(--ai-font-weight-medium);
34+
cursor: pointer;
35+
text-decoration: underline;
36+
transition: opacity 0.2s ease;
37+
white-space: nowrap;
38+
}
39+
40+
.toggleButton:hover {
41+
opacity: 0.8;
42+
}
43+
44+
.toggleButton:active {
45+
opacity: 0.6;
46+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
declare const styles: {
2+
readonly expandableText: string;
3+
readonly text: string;
4+
readonly clamped: string;
5+
readonly toggleButton: string;
6+
};
7+
8+
export default styles;
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import React, { useState, useRef, useEffect } from 'react';
2+
import { cn } from '../../utils/cn';
3+
import styles from './ExpandableText.module.css';
4+
5+
export interface ExpandableTextProps {
6+
/**
7+
* Text content to display
8+
*/
9+
text: string;
10+
11+
/**
12+
* Maximum number of lines to show when collapsed
13+
* @default 3
14+
*/
15+
maxLines?: number;
16+
17+
/**
18+
* Label for expand button
19+
* @default 'View more'
20+
*/
21+
expandLabel?: string;
22+
23+
/**
24+
* Label for collapse button
25+
* @default 'View less'
26+
*/
27+
collapseLabel?: string;
28+
29+
/**
30+
* Additional CSS class name
31+
*/
32+
className?: string;
33+
34+
/**
35+
* Force expanded state (controlled component)
36+
*/
37+
expanded?: boolean;
38+
39+
/**
40+
* Callback when expanded state changes
41+
*/
42+
onExpandChange?: (expanded: boolean) => void;
43+
}
44+
45+
/**
46+
* ExpandableText - Text component with line-clamp and expand/collapse functionality.
47+
* Automatically detects if text needs truncation.
48+
*
49+
* @example
50+
* ```tsx
51+
* <ExpandableText
52+
* text="Long description text..."
53+
* maxLines={3}
54+
* expandLabel="Read more"
55+
* collapseLabel="Read less"
56+
* />
57+
* ```
58+
*/
59+
export const ExpandableText: React.FC<ExpandableTextProps> = ({
60+
text,
61+
maxLines = 3,
62+
expandLabel = 'View more',
63+
collapseLabel = 'View less',
64+
className,
65+
expanded: controlledExpanded,
66+
onExpandChange,
67+
}) => {
68+
const [internalExpanded, setInternalExpanded] = useState(false);
69+
const [needsTruncation, setNeedsTruncation] = useState(false);
70+
const textRef = useRef<HTMLParagraphElement>(null);
71+
72+
const isControlled = controlledExpanded !== undefined;
73+
const expanded = isControlled ? controlledExpanded : internalExpanded;
74+
75+
// Detect if text needs truncation
76+
useEffect(() => {
77+
const element = textRef.current;
78+
if (!element) return;
79+
80+
// Compare scroll height with client height to determine if text is truncated
81+
const isOverflowing = element.scrollHeight > element.clientHeight;
82+
setNeedsTruncation(isOverflowing);
83+
}, [text, maxLines]);
84+
85+
const handleToggle = () => {
86+
if (isControlled) {
87+
onExpandChange?.(!expanded);
88+
} else {
89+
setInternalExpanded(!expanded);
90+
onExpandChange?.(!expanded);
91+
}
92+
};
93+
94+
return (
95+
<div className={cn(styles.expandableText, className)}>
96+
<p
97+
ref={textRef}
98+
className={cn(styles.text, !expanded && styles.clamped)}
99+
style={{
100+
WebkitLineClamp: expanded ? 'unset' : maxLines,
101+
}}
102+
>
103+
{text}
104+
{needsTruncation && (
105+
<button
106+
className={styles.toggleButton}
107+
onClick={handleToggle}
108+
type="button"
109+
aria-expanded={expanded}
110+
>
111+
{expanded ? collapseLabel : expandLabel}
112+
</button>
113+
)}
114+
</p>
115+
</div>
116+
);
117+
};
118+
119+
ExpandableText.displayName = 'ExpandableText';
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { ExpandableText } from './ExpandableText';
2+
export type { ExpandableTextProps } from './ExpandableText';

0 commit comments

Comments
 (0)