diff --git a/.claude/edges.md b/.claude/edges.md index 1bb35952..791ffca5 100644 --- a/.claude/edges.md +++ b/.claude/edges.md @@ -223,7 +223,7 @@ The following components are available from @texturehq/edges: - **SparklineCell** - Component - **SplitPane** - Child panels - **StaticMap** - Component -- **StatList** - Additional CSS classes */ className?: string; } // Helpers // Use the centralized formatting utility function formatValue(value: StatValue, formatter?: StatFormatter): React.ReactNode { // If value is already a React element, return it as-is (skip formatting) if (React.isValidElement(value)) { return value; } return formatComponentValue({ value, formatter, emptyClassName: "text-text-muted", emptyText: "—", }); } function getTone(item: StatItem): StatTone | undefined { // Check thresholds first if (item.thresholds && item.value !== null && item.value !== undefined) { for (const threshold of item.thresholds) { if (threshold.when(item.value)) { return threshold.tone; } } } // Fall back to explicit tone return item.tone; } const toneColors: Record = { neutral: "text-text-body", success: "text-feedback-success", warning: "text-feedback-warning", error: "text-feedback-error", info: "text-feedback-info", }; // StatRow Component // Constants for auto-truncation const LONG_STRING_THRESHOLD = 24; const LONG_STRING_TRUNCATE_LENGTH = 20; function StatRow({ item, dense, valueAlign, }: { item: StatItem; dense?: boolean; valueAlign?: StatAlign; }) { const tone = getTone(item); const toneClass = tone ? toneColors[tone] : ""; const isStacked = item.stackOnMobile; // Check if this is a long string that should be auto-truncated on mobile const isLongString = typeof item.value === "string" && item.value.length > LONG_STRING_THRESHOLD && !item.formatter && !React.isValidElement(item.value); // Get the formatted value (used for both truncated and full display) const formattedValue = formatValue(item.value, item.formatter); // Get the truncated value for mobile display // Note: We cast to string here because isLongString already verifies typeof item.value === "string" const truncatedValue = isLongString ? truncateMiddle(item.value as string, LONG_STRING_TRUNCATE_LENGTH) : null; const textToCopy = typeof item.copyable === "function" ? item.copyable(item.value) : String(item.value); // PII data attributes (only set if both piiType and piiEntity are provided) const piiAttrs = item.piiType && item.piiEntity ? { "data-pii-type": item.piiType, "data-pii-entity": item.piiEntity, } : {}; // For long strings, render both truncated (mobile) and full (desktop) versions // CSS classes control which is visible based on screen size // Tooltip content is wrapped in span with PII attributes to allow masking by Curtain extension const hasPiiAttrs = Object.keys(piiAttrs).length > 0; const valueDisplay = isLongString ? ( <> {/* Mobile: show truncated with tooltip */} {String(item.value)} : String(item.value)} > {truncatedValue} {/* Desktop: show full value +- **StatList** - Additional CSS classes */ className?: string; } // Helpers // Use the centralized formatting utility function formatValue(value: StatValue, formatter?: StatFormatter): React.ReactNode { // If value is already a React element, return it as-is (skip formatting) if (React.isValidElement(value)) { return value; } return formatComponentValue({ value, formatter, emptyClassName: "text-text-muted", emptyText: "—", }); } function getTone(item: StatItem): StatTone | undefined { // Check thresholds first if (item.thresholds && item.value !== null && item.value !== undefined) { for (const threshold of item.thresholds) { if (threshold.when(item.value)) { return threshold.tone; } } } // Fall back to explicit tone return item.tone; } const toneColors: Record = { neutral: "text-text-body", success: "text-feedback-success", warning: "text-feedback-warning", error: "text-feedback-error", info: "text-feedback-info", }; // StatRow Component // Constants for auto-truncation const LONG_STRING_THRESHOLD = 24; const LONG_STRING_TRUNCATE_LENGTH = 20; function StatRow({ item, dense, valueAlign }: { item: StatItem; dense?: boolean; valueAlign?: StatAlign }) { const tone = getTone(item); const toneClass = tone ? toneColors[tone] : ""; const isStacked = item.stackOnMobile; // Check if this is a long string that should be auto-truncated on mobile const isLongString = typeof item.value === "string" && item.value.length > LONG_STRING_THRESHOLD && !item.formatter && !React.isValidElement(item.value); // Get the formatted value (used for both truncated and full display) const formattedValue = formatValue(item.value, item.formatter); // Get the truncated value for mobile display // Note: We cast to string here because isLongString already verifies typeof item.value === "string" const truncatedValue = isLongString ? truncateMiddle(item.value as string, LONG_STRING_TRUNCATE_LENGTH) : null; const textToCopy = typeof item.copyable === "function" ? item.copyable(item.value) : String(item.value); // PII data attributes (only set if both piiType and piiEntity are provided) const piiAttrs = item.piiType && item.piiEntity ? { "data-pii-type": item.piiType, "data-pii-entity": item.piiEntity, } : {}; // For long strings, render both truncated (mobile) and full (desktop) versions // CSS classes control which is visible based on screen size // Tooltip content is wrapped in span with PII attributes to allow masking by Curtain extension const hasPiiAttrs = Object.keys(piiAttrs).length > 0; const valueDisplay = isLongString ? ( <> {/* Mobile: show truncated with tooltip */} {String(item.value)} : String(item.value)}> {truncatedValue} {/* Desktop: show full value - **Switch** - Switch Toggle switch component for binary on/off states. Provides an accessible alternative to checkboxes for settings and preferences. - **Tab** - Tab trigger element. - **TabList** - TabList container. @@ -451,7 +451,7 @@ Renders an Edges Button. When `href` is provided, renders a link-styled button u - `size: Size` - `style: React.CSSProperties` - `target: string` -- `variant: | "default" | "brand" | "secondary" | "destructive" | "icon" | "link" | "unstyled" | "ghost" | "primary"` +- `variant: "default" | "brand" | "secondary" | "destructive" | "icon" | "link" | "unstyled" | "ghost" | "primary"` --- @@ -1438,7 +1438,7 @@ PlaceSearch Location search component with autocomplete; emits a `Place` value **Props:** - `autoFocus: boolean` -- `countryRestrictions: string[]; // Array of ISO 3166-1 alpha-2 country codes proximity?: "ip" | [number, number]; // Either "ip" for IP-based location or [longitude, latitude] coordinates hideCountry?: boolean; // Hide country from results (useful when using countryRestrictions) showIcon?: boolean; // Show location type icons in results (default: true) className?: string` +- `countryRestrictions: string[]; // Array of ISO 3166-1 alpha-2 country codes proximity?: "ip" | [number, number]; // Either "ip" for IP-based location or [longitude, latitude] coordinates hideCountry?: boolean; // Hide country from results (useful when using countryRestrictions) showIcon?: boolean; // Show location type icons in results (default: true) useMobileTray?: boolean; // Whether to use a Tray (bottom sheet) on mobile devices instead of a Popover (default: true) className?: string` - `defaultFilter: (textValue: string, inputValue: string) => boolean` - `defaultSelectedKey: Key | null` - `description: string` @@ -1763,7 +1763,7 @@ Child panels --- #### StatList -Additional CSS classes */ className?: string; } // Helpers // Use the centralized formatting utility function formatValue(value: StatValue, formatter?: StatFormatter): React.ReactNode { // If value is already a React element, return it as-is (skip formatting) if (React.isValidElement(value)) { return value; } return formatComponentValue({ value, formatter, emptyClassName: "text-text-muted", emptyText: "—", }); } function getTone(item: StatItem): StatTone | undefined { // Check thresholds first if (item.thresholds && item.value !== null && item.value !== undefined) { for (const threshold of item.thresholds) { if (threshold.when(item.value)) { return threshold.tone; } } } // Fall back to explicit tone return item.tone; } const toneColors: Record = { neutral: "text-text-body", success: "text-feedback-success", warning: "text-feedback-warning", error: "text-feedback-error", info: "text-feedback-info", }; // StatRow Component // Constants for auto-truncation const LONG_STRING_THRESHOLD = 24; const LONG_STRING_TRUNCATE_LENGTH = 20; function StatRow({ item, dense, valueAlign, }: { item: StatItem; dense?: boolean; valueAlign?: StatAlign; }) { const tone = getTone(item); const toneClass = tone ? toneColors[tone] : ""; const isStacked = item.stackOnMobile; // Check if this is a long string that should be auto-truncated on mobile const isLongString = typeof item.value === "string" && item.value.length > LONG_STRING_THRESHOLD && !item.formatter && !React.isValidElement(item.value); // Get the formatted value (used for both truncated and full display) const formattedValue = formatValue(item.value, item.formatter); // Get the truncated value for mobile display // Note: We cast to string here because isLongString already verifies typeof item.value === "string" const truncatedValue = isLongString ? truncateMiddle(item.value as string, LONG_STRING_TRUNCATE_LENGTH) : null; const textToCopy = typeof item.copyable === "function" ? item.copyable(item.value) : String(item.value); // PII data attributes (only set if both piiType and piiEntity are provided) const piiAttrs = item.piiType && item.piiEntity ? { "data-pii-type": item.piiType, "data-pii-entity": item.piiEntity, } : {}; // For long strings, render both truncated (mobile) and full (desktop) versions // CSS classes control which is visible based on screen size // Tooltip content is wrapped in span with PII attributes to allow masking by Curtain extension const hasPiiAttrs = Object.keys(piiAttrs).length > 0; const valueDisplay = isLongString ? ( <> {/* Mobile: show truncated with tooltip */} {String(item.value)} : String(item.value)} > {truncatedValue} {/* Desktop: show full value +Additional CSS classes */ className?: string; } // Helpers // Use the centralized formatting utility function formatValue(value: StatValue, formatter?: StatFormatter): React.ReactNode { // If value is already a React element, return it as-is (skip formatting) if (React.isValidElement(value)) { return value; } return formatComponentValue({ value, formatter, emptyClassName: "text-text-muted", emptyText: "—", }); } function getTone(item: StatItem): StatTone | undefined { // Check thresholds first if (item.thresholds && item.value !== null && item.value !== undefined) { for (const threshold of item.thresholds) { if (threshold.when(item.value)) { return threshold.tone; } } } // Fall back to explicit tone return item.tone; } const toneColors: Record = { neutral: "text-text-body", success: "text-feedback-success", warning: "text-feedback-warning", error: "text-feedback-error", info: "text-feedback-info", }; // StatRow Component // Constants for auto-truncation const LONG_STRING_THRESHOLD = 24; const LONG_STRING_TRUNCATE_LENGTH = 20; function StatRow({ item, dense, valueAlign }: { item: StatItem; dense?: boolean; valueAlign?: StatAlign }) { const tone = getTone(item); const toneClass = tone ? toneColors[tone] : ""; const isStacked = item.stackOnMobile; // Check if this is a long string that should be auto-truncated on mobile const isLongString = typeof item.value === "string" && item.value.length > LONG_STRING_THRESHOLD && !item.formatter && !React.isValidElement(item.value); // Get the formatted value (used for both truncated and full display) const formattedValue = formatValue(item.value, item.formatter); // Get the truncated value for mobile display // Note: We cast to string here because isLongString already verifies typeof item.value === "string" const truncatedValue = isLongString ? truncateMiddle(item.value as string, LONG_STRING_TRUNCATE_LENGTH) : null; const textToCopy = typeof item.copyable === "function" ? item.copyable(item.value) : String(item.value); // PII data attributes (only set if both piiType and piiEntity are provided) const piiAttrs = item.piiType && item.piiEntity ? { "data-pii-type": item.piiType, "data-pii-entity": item.piiEntity, } : {}; // For long strings, render both truncated (mobile) and full (desktop) versions // CSS classes control which is visible based on screen size // Tooltip content is wrapped in span with PII attributes to allow masking by Curtain extension const hasPiiAttrs = Object.keys(piiAttrs).length > 0; const valueDisplay = isLongString ? ( <> {/* Mobile: show truncated with tooltip */} {String(item.value)} : String(item.value)}> {truncatedValue} {/* Desktop: show full value **Imports:** - `import { StatList } from "@texturehq/edges"` diff --git a/.codex/edges.md b/.codex/edges.md index 1bb35952..791ffca5 100644 --- a/.codex/edges.md +++ b/.codex/edges.md @@ -223,7 +223,7 @@ The following components are available from @texturehq/edges: - **SparklineCell** - Component - **SplitPane** - Child panels - **StaticMap** - Component -- **StatList** - Additional CSS classes */ className?: string; } // Helpers // Use the centralized formatting utility function formatValue(value: StatValue, formatter?: StatFormatter): React.ReactNode { // If value is already a React element, return it as-is (skip formatting) if (React.isValidElement(value)) { return value; } return formatComponentValue({ value, formatter, emptyClassName: "text-text-muted", emptyText: "—", }); } function getTone(item: StatItem): StatTone | undefined { // Check thresholds first if (item.thresholds && item.value !== null && item.value !== undefined) { for (const threshold of item.thresholds) { if (threshold.when(item.value)) { return threshold.tone; } } } // Fall back to explicit tone return item.tone; } const toneColors: Record = { neutral: "text-text-body", success: "text-feedback-success", warning: "text-feedback-warning", error: "text-feedback-error", info: "text-feedback-info", }; // StatRow Component // Constants for auto-truncation const LONG_STRING_THRESHOLD = 24; const LONG_STRING_TRUNCATE_LENGTH = 20; function StatRow({ item, dense, valueAlign, }: { item: StatItem; dense?: boolean; valueAlign?: StatAlign; }) { const tone = getTone(item); const toneClass = tone ? toneColors[tone] : ""; const isStacked = item.stackOnMobile; // Check if this is a long string that should be auto-truncated on mobile const isLongString = typeof item.value === "string" && item.value.length > LONG_STRING_THRESHOLD && !item.formatter && !React.isValidElement(item.value); // Get the formatted value (used for both truncated and full display) const formattedValue = formatValue(item.value, item.formatter); // Get the truncated value for mobile display // Note: We cast to string here because isLongString already verifies typeof item.value === "string" const truncatedValue = isLongString ? truncateMiddle(item.value as string, LONG_STRING_TRUNCATE_LENGTH) : null; const textToCopy = typeof item.copyable === "function" ? item.copyable(item.value) : String(item.value); // PII data attributes (only set if both piiType and piiEntity are provided) const piiAttrs = item.piiType && item.piiEntity ? { "data-pii-type": item.piiType, "data-pii-entity": item.piiEntity, } : {}; // For long strings, render both truncated (mobile) and full (desktop) versions // CSS classes control which is visible based on screen size // Tooltip content is wrapped in span with PII attributes to allow masking by Curtain extension const hasPiiAttrs = Object.keys(piiAttrs).length > 0; const valueDisplay = isLongString ? ( <> {/* Mobile: show truncated with tooltip */} {String(item.value)} : String(item.value)} > {truncatedValue} {/* Desktop: show full value +- **StatList** - Additional CSS classes */ className?: string; } // Helpers // Use the centralized formatting utility function formatValue(value: StatValue, formatter?: StatFormatter): React.ReactNode { // If value is already a React element, return it as-is (skip formatting) if (React.isValidElement(value)) { return value; } return formatComponentValue({ value, formatter, emptyClassName: "text-text-muted", emptyText: "—", }); } function getTone(item: StatItem): StatTone | undefined { // Check thresholds first if (item.thresholds && item.value !== null && item.value !== undefined) { for (const threshold of item.thresholds) { if (threshold.when(item.value)) { return threshold.tone; } } } // Fall back to explicit tone return item.tone; } const toneColors: Record = { neutral: "text-text-body", success: "text-feedback-success", warning: "text-feedback-warning", error: "text-feedback-error", info: "text-feedback-info", }; // StatRow Component // Constants for auto-truncation const LONG_STRING_THRESHOLD = 24; const LONG_STRING_TRUNCATE_LENGTH = 20; function StatRow({ item, dense, valueAlign }: { item: StatItem; dense?: boolean; valueAlign?: StatAlign }) { const tone = getTone(item); const toneClass = tone ? toneColors[tone] : ""; const isStacked = item.stackOnMobile; // Check if this is a long string that should be auto-truncated on mobile const isLongString = typeof item.value === "string" && item.value.length > LONG_STRING_THRESHOLD && !item.formatter && !React.isValidElement(item.value); // Get the formatted value (used for both truncated and full display) const formattedValue = formatValue(item.value, item.formatter); // Get the truncated value for mobile display // Note: We cast to string here because isLongString already verifies typeof item.value === "string" const truncatedValue = isLongString ? truncateMiddle(item.value as string, LONG_STRING_TRUNCATE_LENGTH) : null; const textToCopy = typeof item.copyable === "function" ? item.copyable(item.value) : String(item.value); // PII data attributes (only set if both piiType and piiEntity are provided) const piiAttrs = item.piiType && item.piiEntity ? { "data-pii-type": item.piiType, "data-pii-entity": item.piiEntity, } : {}; // For long strings, render both truncated (mobile) and full (desktop) versions // CSS classes control which is visible based on screen size // Tooltip content is wrapped in span with PII attributes to allow masking by Curtain extension const hasPiiAttrs = Object.keys(piiAttrs).length > 0; const valueDisplay = isLongString ? ( <> {/* Mobile: show truncated with tooltip */} {String(item.value)} : String(item.value)}> {truncatedValue} {/* Desktop: show full value - **Switch** - Switch Toggle switch component for binary on/off states. Provides an accessible alternative to checkboxes for settings and preferences. - **Tab** - Tab trigger element. - **TabList** - TabList container. @@ -451,7 +451,7 @@ Renders an Edges Button. When `href` is provided, renders a link-styled button u - `size: Size` - `style: React.CSSProperties` - `target: string` -- `variant: | "default" | "brand" | "secondary" | "destructive" | "icon" | "link" | "unstyled" | "ghost" | "primary"` +- `variant: "default" | "brand" | "secondary" | "destructive" | "icon" | "link" | "unstyled" | "ghost" | "primary"` --- @@ -1438,7 +1438,7 @@ PlaceSearch Location search component with autocomplete; emits a `Place` value **Props:** - `autoFocus: boolean` -- `countryRestrictions: string[]; // Array of ISO 3166-1 alpha-2 country codes proximity?: "ip" | [number, number]; // Either "ip" for IP-based location or [longitude, latitude] coordinates hideCountry?: boolean; // Hide country from results (useful when using countryRestrictions) showIcon?: boolean; // Show location type icons in results (default: true) className?: string` +- `countryRestrictions: string[]; // Array of ISO 3166-1 alpha-2 country codes proximity?: "ip" | [number, number]; // Either "ip" for IP-based location or [longitude, latitude] coordinates hideCountry?: boolean; // Hide country from results (useful when using countryRestrictions) showIcon?: boolean; // Show location type icons in results (default: true) useMobileTray?: boolean; // Whether to use a Tray (bottom sheet) on mobile devices instead of a Popover (default: true) className?: string` - `defaultFilter: (textValue: string, inputValue: string) => boolean` - `defaultSelectedKey: Key | null` - `description: string` @@ -1763,7 +1763,7 @@ Child panels --- #### StatList -Additional CSS classes */ className?: string; } // Helpers // Use the centralized formatting utility function formatValue(value: StatValue, formatter?: StatFormatter): React.ReactNode { // If value is already a React element, return it as-is (skip formatting) if (React.isValidElement(value)) { return value; } return formatComponentValue({ value, formatter, emptyClassName: "text-text-muted", emptyText: "—", }); } function getTone(item: StatItem): StatTone | undefined { // Check thresholds first if (item.thresholds && item.value !== null && item.value !== undefined) { for (const threshold of item.thresholds) { if (threshold.when(item.value)) { return threshold.tone; } } } // Fall back to explicit tone return item.tone; } const toneColors: Record = { neutral: "text-text-body", success: "text-feedback-success", warning: "text-feedback-warning", error: "text-feedback-error", info: "text-feedback-info", }; // StatRow Component // Constants for auto-truncation const LONG_STRING_THRESHOLD = 24; const LONG_STRING_TRUNCATE_LENGTH = 20; function StatRow({ item, dense, valueAlign, }: { item: StatItem; dense?: boolean; valueAlign?: StatAlign; }) { const tone = getTone(item); const toneClass = tone ? toneColors[tone] : ""; const isStacked = item.stackOnMobile; // Check if this is a long string that should be auto-truncated on mobile const isLongString = typeof item.value === "string" && item.value.length > LONG_STRING_THRESHOLD && !item.formatter && !React.isValidElement(item.value); // Get the formatted value (used for both truncated and full display) const formattedValue = formatValue(item.value, item.formatter); // Get the truncated value for mobile display // Note: We cast to string here because isLongString already verifies typeof item.value === "string" const truncatedValue = isLongString ? truncateMiddle(item.value as string, LONG_STRING_TRUNCATE_LENGTH) : null; const textToCopy = typeof item.copyable === "function" ? item.copyable(item.value) : String(item.value); // PII data attributes (only set if both piiType and piiEntity are provided) const piiAttrs = item.piiType && item.piiEntity ? { "data-pii-type": item.piiType, "data-pii-entity": item.piiEntity, } : {}; // For long strings, render both truncated (mobile) and full (desktop) versions // CSS classes control which is visible based on screen size // Tooltip content is wrapped in span with PII attributes to allow masking by Curtain extension const hasPiiAttrs = Object.keys(piiAttrs).length > 0; const valueDisplay = isLongString ? ( <> {/* Mobile: show truncated with tooltip */} {String(item.value)} : String(item.value)} > {truncatedValue} {/* Desktop: show full value +Additional CSS classes */ className?: string; } // Helpers // Use the centralized formatting utility function formatValue(value: StatValue, formatter?: StatFormatter): React.ReactNode { // If value is already a React element, return it as-is (skip formatting) if (React.isValidElement(value)) { return value; } return formatComponentValue({ value, formatter, emptyClassName: "text-text-muted", emptyText: "—", }); } function getTone(item: StatItem): StatTone | undefined { // Check thresholds first if (item.thresholds && item.value !== null && item.value !== undefined) { for (const threshold of item.thresholds) { if (threshold.when(item.value)) { return threshold.tone; } } } // Fall back to explicit tone return item.tone; } const toneColors: Record = { neutral: "text-text-body", success: "text-feedback-success", warning: "text-feedback-warning", error: "text-feedback-error", info: "text-feedback-info", }; // StatRow Component // Constants for auto-truncation const LONG_STRING_THRESHOLD = 24; const LONG_STRING_TRUNCATE_LENGTH = 20; function StatRow({ item, dense, valueAlign }: { item: StatItem; dense?: boolean; valueAlign?: StatAlign }) { const tone = getTone(item); const toneClass = tone ? toneColors[tone] : ""; const isStacked = item.stackOnMobile; // Check if this is a long string that should be auto-truncated on mobile const isLongString = typeof item.value === "string" && item.value.length > LONG_STRING_THRESHOLD && !item.formatter && !React.isValidElement(item.value); // Get the formatted value (used for both truncated and full display) const formattedValue = formatValue(item.value, item.formatter); // Get the truncated value for mobile display // Note: We cast to string here because isLongString already verifies typeof item.value === "string" const truncatedValue = isLongString ? truncateMiddle(item.value as string, LONG_STRING_TRUNCATE_LENGTH) : null; const textToCopy = typeof item.copyable === "function" ? item.copyable(item.value) : String(item.value); // PII data attributes (only set if both piiType and piiEntity are provided) const piiAttrs = item.piiType && item.piiEntity ? { "data-pii-type": item.piiType, "data-pii-entity": item.piiEntity, } : {}; // For long strings, render both truncated (mobile) and full (desktop) versions // CSS classes control which is visible based on screen size // Tooltip content is wrapped in span with PII attributes to allow masking by Curtain extension const hasPiiAttrs = Object.keys(piiAttrs).length > 0; const valueDisplay = isLongString ? ( <> {/* Mobile: show truncated with tooltip */} {String(item.value)} : String(item.value)}> {truncatedValue} {/* Desktop: show full value **Imports:** - `import { StatList } from "@texturehq/edges"` diff --git a/.cursor/rules/edges-components.mdc b/.cursor/rules/edges-components.mdc index f900733a..dedbacbd 100644 --- a/.cursor/rules/edges-components.mdc +++ b/.cursor/rules/edges-components.mdc @@ -2,7 +2,7 @@ alwaysApply: true --- -## @texturehq/edges Components (v1.28.0) +## @texturehq/edges Components (v1.28.2) ### Quick Reference @@ -157,7 +157,7 @@ alwaysApply: true - **SparklineCell** - Component - **SplitPane** - Child panels - **StaticMap** - Component -- **StatList** - Additional CSS classes */ className?: string; } // Helpers // Use the centralized formatting utility function formatValue(value: StatValue, formatter?: StatFormatter): React.ReactNode { // If value is already a React element, return it as-is (skip formatting) if (React.isValidElement(value)) { return value; } return formatComponentValue({ value, formatter, emptyClassName: "text-text-muted", emptyText: "—", }); } function getTone(item: StatItem): StatTone | undefined { // Check thresholds first if (item.thresholds && item.value !== null && item.value !== undefined) { for (const threshold of item.thresholds) { if (threshold.when(item.value)) { return threshold.tone; } } } // Fall back to explicit tone return item.tone; } const toneColors: Record = { neutral: "text-text-body", success: "text-feedback-success", warning: "text-feedback-warning", error: "text-feedback-error", info: "text-feedback-info", }; // StatRow Component // Constants for auto-truncation const LONG_STRING_THRESHOLD = 24; const LONG_STRING_TRUNCATE_LENGTH = 20; function StatRow({ item, dense, valueAlign, }: { item: StatItem; dense?: boolean; valueAlign?: StatAlign; }) { const tone = getTone(item); const toneClass = tone ? toneColors[tone] : ""; const isStacked = item.stackOnMobile; // Check if this is a long string that should be auto-truncated on mobile const isLongString = typeof item.value === "string" && item.value.length > LONG_STRING_THRESHOLD && !item.formatter && !React.isValidElement(item.value); // Get the formatted value (used for both truncated and full display) const formattedValue = formatValue(item.value, item.formatter); // Get the truncated value for mobile display // Note: We cast to string here because isLongString already verifies typeof item.value === "string" const truncatedValue = isLongString ? truncateMiddle(item.value as string, LONG_STRING_TRUNCATE_LENGTH) : null; const textToCopy = typeof item.copyable === "function" ? item.copyable(item.value) : String(item.value); // PII data attributes (only set if both piiType and piiEntity are provided) const piiAttrs = item.piiType && item.piiEntity ? { "data-pii-type": item.piiType, "data-pii-entity": item.piiEntity, } : {}; // For long strings, render both truncated (mobile) and full (desktop) versions // CSS classes control which is visible based on screen size // Tooltip content is wrapped in span with PII attributes to allow masking by Curtain extension const hasPiiAttrs = Object.keys(piiAttrs).length > 0; const valueDisplay = isLongString ? ( <> {/* Mobile: show truncated with tooltip */} {String(item.value)} : String(item.value)} > {truncatedValue} {/* Desktop: show full value +- **StatList** - Additional CSS classes */ className?: string; } // Helpers // Use the centralized formatting utility function formatValue(value: StatValue, formatter?: StatFormatter): React.ReactNode { // If value is already a React element, return it as-is (skip formatting) if (React.isValidElement(value)) { return value; } return formatComponentValue({ value, formatter, emptyClassName: "text-text-muted", emptyText: "—", }); } function getTone(item: StatItem): StatTone | undefined { // Check thresholds first if (item.thresholds && item.value !== null && item.value !== undefined) { for (const threshold of item.thresholds) { if (threshold.when(item.value)) { return threshold.tone; } } } // Fall back to explicit tone return item.tone; } const toneColors: Record = { neutral: "text-text-body", success: "text-feedback-success", warning: "text-feedback-warning", error: "text-feedback-error", info: "text-feedback-info", }; // StatRow Component // Constants for auto-truncation const LONG_STRING_THRESHOLD = 24; const LONG_STRING_TRUNCATE_LENGTH = 20; function StatRow({ item, dense, valueAlign }: { item: StatItem; dense?: boolean; valueAlign?: StatAlign }) { const tone = getTone(item); const toneClass = tone ? toneColors[tone] : ""; const isStacked = item.stackOnMobile; // Check if this is a long string that should be auto-truncated on mobile const isLongString = typeof item.value === "string" && item.value.length > LONG_STRING_THRESHOLD && !item.formatter && !React.isValidElement(item.value); // Get the formatted value (used for both truncated and full display) const formattedValue = formatValue(item.value, item.formatter); // Get the truncated value for mobile display // Note: We cast to string here because isLongString already verifies typeof item.value === "string" const truncatedValue = isLongString ? truncateMiddle(item.value as string, LONG_STRING_TRUNCATE_LENGTH) : null; const textToCopy = typeof item.copyable === "function" ? item.copyable(item.value) : String(item.value); // PII data attributes (only set if both piiType and piiEntity are provided) const piiAttrs = item.piiType && item.piiEntity ? { "data-pii-type": item.piiType, "data-pii-entity": item.piiEntity, } : {}; // For long strings, render both truncated (mobile) and full (desktop) versions // CSS classes control which is visible based on screen size // Tooltip content is wrapped in span with PII attributes to allow masking by Curtain extension const hasPiiAttrs = Object.keys(piiAttrs).length > 0; const valueDisplay = isLongString ? ( <> {/* Mobile: show truncated with tooltip */} {String(item.value)} : String(item.value)}> {truncatedValue} {/* Desktop: show full value - **Switch** - Switch Toggle switch component for binary on/off states. Provides an accessible alternative to checkboxes for settings and preferences. - **Tab** - Tab trigger element. - **TabList** - TabList container. @@ -385,7 +385,7 @@ Renders an Edges Button. When `href` is provided, renders a link-styled button u - `size: Size` - `style: React.CSSProperties` - `target: string` -- `variant: | "default" | "brand" | "secondary" | "destructive" | "icon" | "link" | "unstyled" | "ghost" | "primary"` +- `variant: "default" | "brand" | "secondary" | "destructive" | "icon" | "link" | "unstyled" | "ghost" | "primary"` --- @@ -1372,7 +1372,7 @@ PlaceSearch Location search component with autocomplete; emits a `Place` value **Props:** - `autoFocus: boolean` -- `countryRestrictions: string[]; // Array of ISO 3166-1 alpha-2 country codes proximity?: "ip" | [number, number]; // Either "ip" for IP-based location or [longitude, latitude] coordinates hideCountry?: boolean; // Hide country from results (useful when using countryRestrictions) showIcon?: boolean; // Show location type icons in results (default: true) className?: string` +- `countryRestrictions: string[]; // Array of ISO 3166-1 alpha-2 country codes proximity?: "ip" | [number, number]; // Either "ip" for IP-based location or [longitude, latitude] coordinates hideCountry?: boolean; // Hide country from results (useful when using countryRestrictions) showIcon?: boolean; // Show location type icons in results (default: true) useMobileTray?: boolean; // Whether to use a Tray (bottom sheet) on mobile devices instead of a Popover (default: true) className?: string` - `defaultFilter: (textValue: string, inputValue: string) => boolean` - `defaultSelectedKey: Key | null` - `description: string` @@ -1697,7 +1697,7 @@ Child panels --- #### StatList -Additional CSS classes */ className?: string; } // Helpers // Use the centralized formatting utility function formatValue(value: StatValue, formatter?: StatFormatter): React.ReactNode { // If value is already a React element, return it as-is (skip formatting) if (React.isValidElement(value)) { return value; } return formatComponentValue({ value, formatter, emptyClassName: "text-text-muted", emptyText: "—", }); } function getTone(item: StatItem): StatTone | undefined { // Check thresholds first if (item.thresholds && item.value !== null && item.value !== undefined) { for (const threshold of item.thresholds) { if (threshold.when(item.value)) { return threshold.tone; } } } // Fall back to explicit tone return item.tone; } const toneColors: Record = { neutral: "text-text-body", success: "text-feedback-success", warning: "text-feedback-warning", error: "text-feedback-error", info: "text-feedback-info", }; // StatRow Component // Constants for auto-truncation const LONG_STRING_THRESHOLD = 24; const LONG_STRING_TRUNCATE_LENGTH = 20; function StatRow({ item, dense, valueAlign, }: { item: StatItem; dense?: boolean; valueAlign?: StatAlign; }) { const tone = getTone(item); const toneClass = tone ? toneColors[tone] : ""; const isStacked = item.stackOnMobile; // Check if this is a long string that should be auto-truncated on mobile const isLongString = typeof item.value === "string" && item.value.length > LONG_STRING_THRESHOLD && !item.formatter && !React.isValidElement(item.value); // Get the formatted value (used for both truncated and full display) const formattedValue = formatValue(item.value, item.formatter); // Get the truncated value for mobile display // Note: We cast to string here because isLongString already verifies typeof item.value === "string" const truncatedValue = isLongString ? truncateMiddle(item.value as string, LONG_STRING_TRUNCATE_LENGTH) : null; const textToCopy = typeof item.copyable === "function" ? item.copyable(item.value) : String(item.value); // PII data attributes (only set if both piiType and piiEntity are provided) const piiAttrs = item.piiType && item.piiEntity ? { "data-pii-type": item.piiType, "data-pii-entity": item.piiEntity, } : {}; // For long strings, render both truncated (mobile) and full (desktop) versions // CSS classes control which is visible based on screen size // Tooltip content is wrapped in span with PII attributes to allow masking by Curtain extension const hasPiiAttrs = Object.keys(piiAttrs).length > 0; const valueDisplay = isLongString ? ( <> {/* Mobile: show truncated with tooltip */} {String(item.value)} : String(item.value)} > {truncatedValue} {/* Desktop: show full value +Additional CSS classes */ className?: string; } // Helpers // Use the centralized formatting utility function formatValue(value: StatValue, formatter?: StatFormatter): React.ReactNode { // If value is already a React element, return it as-is (skip formatting) if (React.isValidElement(value)) { return value; } return formatComponentValue({ value, formatter, emptyClassName: "text-text-muted", emptyText: "—", }); } function getTone(item: StatItem): StatTone | undefined { // Check thresholds first if (item.thresholds && item.value !== null && item.value !== undefined) { for (const threshold of item.thresholds) { if (threshold.when(item.value)) { return threshold.tone; } } } // Fall back to explicit tone return item.tone; } const toneColors: Record = { neutral: "text-text-body", success: "text-feedback-success", warning: "text-feedback-warning", error: "text-feedback-error", info: "text-feedback-info", }; // StatRow Component // Constants for auto-truncation const LONG_STRING_THRESHOLD = 24; const LONG_STRING_TRUNCATE_LENGTH = 20; function StatRow({ item, dense, valueAlign }: { item: StatItem; dense?: boolean; valueAlign?: StatAlign }) { const tone = getTone(item); const toneClass = tone ? toneColors[tone] : ""; const isStacked = item.stackOnMobile; // Check if this is a long string that should be auto-truncated on mobile const isLongString = typeof item.value === "string" && item.value.length > LONG_STRING_THRESHOLD && !item.formatter && !React.isValidElement(item.value); // Get the formatted value (used for both truncated and full display) const formattedValue = formatValue(item.value, item.formatter); // Get the truncated value for mobile display // Note: We cast to string here because isLongString already verifies typeof item.value === "string" const truncatedValue = isLongString ? truncateMiddle(item.value as string, LONG_STRING_TRUNCATE_LENGTH) : null; const textToCopy = typeof item.copyable === "function" ? item.copyable(item.value) : String(item.value); // PII data attributes (only set if both piiType and piiEntity are provided) const piiAttrs = item.piiType && item.piiEntity ? { "data-pii-type": item.piiType, "data-pii-entity": item.piiEntity, } : {}; // For long strings, render both truncated (mobile) and full (desktop) versions // CSS classes control which is visible based on screen size // Tooltip content is wrapped in span with PII attributes to allow masking by Curtain extension const hasPiiAttrs = Object.keys(piiAttrs).length > 0; const valueDisplay = isLongString ? ( <> {/* Mobile: show truncated with tooltip */} {String(item.value)} : String(item.value)}> {truncatedValue} {/* Desktop: show full value **Imports:** - `import { StatList } from "@texturehq/edges"` diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..f62306df --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,266 @@ + + + + + + +## Summary + + + + +## Change Type + + +- [ ] `feat` — New feature or capability +- [ ] `fix` — Bug fix +- [ ] `style` — Visual/CSS changes (no logic change) +- [ ] `refactor` — Code restructuring (no behavior change) +- [ ] `docs` — Documentation only +- [ ] `test` — Adding or modifying tests +- [ ] `chore` — Build, CI, dependency, or tooling changes +- [ ] `perf` — Performance improvement + +**User-facing change?** +**Breaking change?** + +## Changeset Overview + + +| Metric | Value | +|--------|-------| +| Commits | | +| Files changed | | +| Insertions (+) | | +| Deletions (−) | | +| Net | | +| PR size | | + +## Test Plan + + + +### Automated +- [ ] `next build` / `npm run build` completes without errors +- [ ] Lint passes (e.g., `npx biome check`) + +### Manual +- [ ] _Describe manual verification steps_ + + + + + +## Motivation + + + + + +## Commit Log + + +| Hash | Type | Scope | Description | Size | Risk | +|------|------|-------|-------------|------|------| +| | | | | | | + +
+Classification Key + +- **Type**: `feat` · `fix` · `style` · `refactor` · `test` · `docs` · `chore` · `perf` +- **Size**: `S` (<20 lines) · `M` (20-80 lines) · `L` (>80 lines) +- **Risk**: `low` (additive/isolated) · `medium` (cross-cutting/visual) · `high` (behavioral/breaking) +- Follows [Conventional Commits v1.0.0](https://www.conventionalcommits.org/en/v1.0.0/) + +
+ +## File Impact Matrix + + +| File | Layer | +/− | Character | +|------|-------|-----|-----------| +| | | | | + +
+Layer & Character Key + +- **Layer**: `page` · `component` · `lib` · `style` · `asset` · `config` · `type` · `test` +- **Character**: _Major feature_ · _New fields_ · _Style-only_ · _Bug fix_ · _Metadata_ · _Refactor_ + +
+ +## Risk Assessment + + +| Change | Risk | Blast Radius | Mitigation | +|--------|------|-------------|------------| +| | | | | + + + High Impact + y-axis Low Risk --> High Risk +``` +--> + +## Reviewer Checklist + + +- [ ] Changeset overview matches actual diff +- [ ] Risk assessment reviewed — no items should be elevated +- [ ] No secrets, credentials, or `.env` values committed +- [ ] Responsive behavior verified at stated breakpoints +- [ ] Accessibility: focus order, aria labels, contrast ratios preserved + + + + + + B[components] + B --> C[pages] +``` +--> + + + + + + + + + + + + + + diff --git a/.gitignore b/.gitignore index ad435e15..a16a177c 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,6 @@ public/tiles/* # Changelog snapshot — large JSON used only for diffing during sync # changelog.json itself is tracked; only the snapshot dir is ignored data/.snapshot/ + +# Visual regression capture output (generated, not tracked) +.visual-regression/ diff --git a/app/(shell)/changelog/page.tsx b/app/(shell)/changelog/page.tsx index 77cb8a20..52f29879 100644 --- a/app/(shell)/changelog/page.tsx +++ b/app/(shell)/changelog/page.tsx @@ -1,6 +1,7 @@ "use client"; import { Badge, Card, Icon, PageLayout, Section } from "@texturehq/edges"; +import { useMemo, useState } from "react"; import { getChangelog } from "@/lib/data"; import type { ChangelogEntry } from "@/types/changelog"; @@ -99,8 +100,8 @@ function EntryRow({ entry }: { entry: ChangelogEntry }) { function DateGroup({ date, entries }: { date: string; entries: ChangelogEntry[] }) { return ( -
-
+
+
{date}
{entries.length} change{entries.length !== 1 ? "s" : ""}
@@ -120,13 +121,41 @@ function DateGroup({ date, entries }: { date: string; entries: ChangelogEntry[] export default function ChangelogPage() { const changelog = getChangelog(); + const [tab, setTab] = useState<"data" | "site">("data"); + const [entityTypeFilter, setEntityTypeFilter] = useState("all"); + const [searchQuery, setSearchQuery] = useState(""); // Merge and sort all entries newest-first - const allEntries = [...changelog.recentlyUpdated, ...changelog.newlyAdded].sort( - (a, b) => new Date(b.isoTimestamp).getTime() - new Date(a.isoTimestamp).getTime(), + const allEntries = useMemo( + () => + [...changelog.recentlyUpdated, ...changelog.newlyAdded].sort( + (a, b) => new Date(b.isoTimestamp).getTime() - new Date(a.isoTimestamp).getTime(), + ), + [changelog], ); - const groups = groupByDate(allEntries); + // Unique entity types for filter chips + const uniqueEntityTypes = useMemo(() => { + const types = new Set(allEntries.map((e) => e.entityType)); + return Array.from(types).sort(); + }, [allEntries]); + + // Filtered entries (by entity type + search) + const filteredEntries = useMemo(() => { + let result = allEntries; + if (entityTypeFilter !== "all") { + result = result.filter((e) => e.entityType === entityTypeFilter); + } + if (searchQuery.trim()) { + const q = searchQuery.toLowerCase(); + result = result.filter( + (e) => e.name.toLowerCase().includes(q) || e.detail.toLowerCase().includes(q), + ); + } + return result; + }, [allEntries, entityTypeFilter, searchQuery]); + + const groups = groupByDate(filteredEntries); const lastUpdated = changelog.updatedAt ? new Date(changelog.updatedAt).toLocaleDateString("en-US", { @@ -137,30 +166,81 @@ export default function ChangelogPage() { : null; return ( - - + -
- {/* Meta bar */} -
-
- - - Synced from authoritative sources daily - -
- {lastUpdated && ( - - Last updated {lastUpdated} - + {/* Pill toggle — upper right */} +
+
+ + +
+
+ + {/* Title + description */} +

Changelog

+
+
+ {tab === "data" && ( + )} + + {tab === "data" + ? "Live changes synced routinely from authoritative energy industry sources." + : "Product updates to the CommonGrid website and tools."} +
+ {tab === "data" && lastUpdated && ( + + Last updated {lastUpdated} + + )} +
+ + {tab === "site" && ( + + + +

Product Updates

+

+ Changes to the CommonGrid website and tools. Visit our{" "} + + GitHub releases + {" "} + for the full history of site updates. +

+
+
+ )} + {tab === "data" && ( +
{/* Stats */} -
+
{[ { label: "Recently updated", @@ -198,26 +278,89 @@ export default function ChangelogPage() { ))}
- {/* Feed */} - {allEntries.length === 0 ? ( - - - -

- No changes recorded yet. Run{" "} - - npm run generate:changelog - {" "} - after a sync to populate this feed. -

-
-
- ) : ( - groups.map(({ date, entries }) => ( - - )) - )} + {/* Filter fieldset — expands to wrap results when a filter is active */} + {(() => { + const isFiltering = entityTypeFilter !== "all" || searchQuery.trim().length > 0; + + const filterControls = ( +
+ {uniqueEntityTypes.length > 1 && ( +
+ {["all", ...uniqueEntityTypes].map((type) => ( + + ))} +
+ )} +
+ + setSearchQuery(e.target.value)} + placeholder="Search..." + className="w-full h-7 pl-8 pr-3 rounded-lg border border-blue-100 dark:border-blue-800/30 bg-blue-50/30 dark:bg-blue-900/10 text-xs text-text-body placeholder:text-text-muted outline-none focus:border-brand-primary transition-colors" + /> +
+
+ ); + + const feed = filteredEntries.length === 0 ? ( + + + +

+ No changes recorded yet. Run{" "} + + npm run generate:changelog + {" "} + after a sync to populate this feed. +

+
+
+ ) : ( + groups.map(({ date, entries }) => ( + + )) + ); + + const filterParts: string[] = []; + if (entityTypeFilter !== "all") { + filterParts.push(entityTypeFilter.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()).replace(/\bIso\b/g, "ISO")); + } + if (searchQuery.trim()) { + filterParts.push(`"${searchQuery.trim()}"`); + } + const filterLabel = `Filtered by ${filterParts.join(" + ")} · ${filteredEntries.length} result${filteredEntries.length !== 1 ? "s" : ""}`; + + return isFiltering ? ( +
+ {filterLabel} + {filterControls} +
{feed}
+
+ ) : ( + <> +
+ Filter + {filterControls} +
+ {feed} + + ); + })()}
+ )} ); diff --git a/app/(shell)/ev-charging/page.tsx b/app/(shell)/ev-charging/page.tsx index 66720287..3ccd2fa7 100644 --- a/app/(shell)/ev-charging/page.tsx +++ b/app/(shell)/ev-charging/page.tsx @@ -37,11 +37,11 @@ interface EVStationRow extends Record { } const sortOptions = [ - { id: "name:asc", label: "Name A-Z", value: "name:asc" }, - { id: "name:desc", label: "Name Z-A", value: "name:desc" }, - { id: "connectors:desc", label: "Most Connectors", value: "connectors:desc" }, - { id: "dcfast:desc", label: "Most DC Fast", value: "dcfast:desc" }, - { id: "state:asc", label: "State A-Z", value: "state:asc" }, + { id: "name:asc", label: "Name ▲", value: "name:asc" }, + { id: "name:desc", label: "Name ▼", value: "name:desc" }, + { id: "connectors:desc", label: "Connectors ▼", value: "connectors:desc" }, + { id: "dcfast:desc", label: "DC Fast ▼", value: "dcfast:desc" }, + { id: "state:asc", label: "State ▲", value: "state:asc" }, ]; function getStatusBadgeVariant(status: string): "success" | "info" | "warning" | "neutral" { @@ -183,7 +183,7 @@ export default function EVChargingPage() { className="w-2.5 h-2.5 rounded-full flex-shrink-0" style={{ backgroundColor: getNetworkColor(row.evNetwork) }} /> - {row.stationName} + {row.stationName} ), mobile: { priority: 1, format: "primary" }, @@ -288,11 +288,11 @@ export default function EVChargingPage() { onChange: setSortValue, }} customControls={ -
+
setLevelFilter(e.target.value)} - className="h-10 sm:h-8 rounded-md border border-border-default bg-background-surface px-2 text-base sm:text-sm text-text-body" + className="h-10 sm:h-8 rounded-md border border-border-default bg-background-surface pl-2 pr-7 text-base sm:text-sm text-text-body" > @@ -313,7 +313,7 @@ export default function EVChargingPage() { setStatusFilter(e.target.value)} - className="h-10 sm:h-8 rounded-md border border-border-default bg-background-surface px-2 text-base sm:text-sm text-text-body" + className="h-10 sm:h-8 rounded-md border border-border-default bg-background-surface pl-2 pr-7 text-base sm:text-sm text-text-body" > @@ -333,7 +333,7 @@ export default function EVChargingPage() { setSegmentFilter(e.target.value)} - className="h-10 sm:h-8 rounded-md border border-border-default bg-background-surface px-2 text-base sm:text-sm text-text-body" + className="h-10 sm:h-8 rounded-md border border-border-default bg-background-surface pl-2 pr-7 text-base sm:text-sm text-text-body" > {segmentFilterOptions.map((opt) => ( {jurisdictions.map((j) => ( diff --git a/app/(shell)/power-plants/page.tsx b/app/(shell)/power-plants/page.tsx index 28b44244..70d42310 100644 --- a/app/(shell)/power-plants/page.tsx +++ b/app/(shell)/power-plants/page.tsx @@ -33,16 +33,19 @@ interface PowerPlantRow extends Record { fuelCategory: string; totalCapacityMw: number; state: string; + county: string | null; utilityName: string; status: string; proposedCapacityMw: number | null; + operatingYear: number | null; + generatorCount: number; } const sortOptions = [ - { id: "name:asc", label: "Name A-Z", value: "name:asc" }, - { id: "name:desc", label: "Name Z-A", value: "name:desc" }, - { id: "capacity:desc", label: "Capacity (High to Low)", value: "capacity:desc" }, - { id: "capacity:asc", label: "Capacity (Low to High)", value: "capacity:asc" }, + { id: "name:asc", label: "Name ▲", value: "name:asc" }, + { id: "name:desc", label: "Name ▼", value: "name:desc" }, + { id: "capacity:desc", label: "Capacity ▼", value: "capacity:desc" }, + { id: "capacity:asc", label: "Capacity ▲", value: "capacity:asc" }, ]; const fuelFilterOptions = [ @@ -128,9 +131,12 @@ export default function PowerPlantsPage() { fuelCategory: p.fuelCategory, totalCapacityMw: p.totalCapacityMw, state: p.state, + county: p.county, utilityName: p.utilityName, status: p.status, proposedCapacityMw: p.proposedCapacityMw, + operatingYear: p.operatingYear, + generatorCount: p.generatorCount, })), [filtered] ); @@ -157,7 +163,7 @@ export default function PowerPlantsPage() { className="w-2.5 h-2.5 rounded-full flex-shrink-0" style={{ backgroundColor: getFuelCategoryColor(row.fuelCategory) }} /> - {row.name} + {row.name} ), mobile: { priority: 1, format: "primary" }, @@ -211,6 +217,33 @@ export default function PowerPlantsPage() { ), mobile: false, }, + { + id: "county", + label: "County", + accessor: "county", + render: (_value: unknown, row: PowerPlantRow) => ( + {row.county ?? "—"} + ), + mobile: false, + }, + { + id: "operatingYear", + label: "Year", + accessor: "operatingYear", + render: (_value: unknown, row: PowerPlantRow) => ( + {row.operatingYear ?? "—"} + ), + mobile: false, + }, + { + id: "generatorCount", + label: "Generators", + accessor: "generatorCount", + render: (_value: unknown, row: PowerPlantRow) => ( + {row.generatorCount} + ), + mobile: false, + }, ], [] ); @@ -253,11 +286,11 @@ export default function PowerPlantsPage() { onChange: setSortValue, }} customControls={ -
+
setStatusFilter(e.target.value)} - className="h-10 sm:h-8 rounded-md border border-border-default bg-background-surface px-2 text-base sm:text-sm text-text-body" + className="h-10 sm:h-8 rounded-md border border-border-default bg-background-surface pl-2 pr-7 text-base sm:text-sm text-text-body" > {statusFilterOptions.map((opt) => ( {nodeTypes.map((type) => ( @@ -265,7 +265,7 @@ export default function PricingNodesPage() { setVoltageFilter(e.target.value)} - className="h-10 sm:h-8 rounded-md border border-border-default bg-background-surface px-2 text-base sm:text-sm text-text-body" + className="h-10 sm:h-8 rounded-md border border-border-default bg-background-surface pl-2 pr-7 text-base sm:text-sm text-text-body" > {voltageClassFilterOptions.map((opt) => (