HeaderSize as shared component

This commit is contained in:
Gregory Schier
2026-03-07 07:32:58 -08:00
parent 6f9e4ada15
commit ff6686f982
20 changed files with 165 additions and 72 deletions

View File

@@ -0,0 +1,95 @@
import classNames from 'classnames';
import type { CSSProperties, HTMLAttributes, ReactNode } from 'react';
import { useMemo } from 'react';
import { useIsFullscreen } from '../hooks/useIsFullscreen';
import { HEADER_SIZE_LG, HEADER_SIZE_MD, WINDOW_CONTROLS_WIDTH } from '../lib/constants';
import { WindowControls } from './WindowControls';
interface HeaderSizeProps extends HTMLAttributes<HTMLDivElement> {
children?: ReactNode;
size: 'md' | 'lg';
ignoreControlsSpacing?: boolean;
onlyXWindowControl?: boolean;
hideControls?: boolean;
osType: string;
hideWindowControls: boolean;
useNativeTitlebar: boolean;
interfaceScale: number;
}
export function HeaderSize({
className,
style,
size,
ignoreControlsSpacing,
onlyXWindowControl,
children,
hideControls,
osType,
hideWindowControls,
useNativeTitlebar,
interfaceScale,
}: HeaderSizeProps) {
const isFullscreen = useIsFullscreen();
const finalStyle = useMemo<CSSProperties>(() => {
const s = { ...style };
// Set the height (use min-height because scaling font size may make it larger
if (size === 'md') s.minHeight = HEADER_SIZE_MD;
if (size === 'lg') s.minHeight = HEADER_SIZE_LG;
if (useNativeTitlebar) {
// No style updates when using native titlebar
} else if (osType === 'macos') {
if (!isFullscreen) {
// Add large padding for window controls
s.paddingLeft = 72 / interfaceScale;
}
} else if (!ignoreControlsSpacing && !hideWindowControls) {
s.paddingRight = WINDOW_CONTROLS_WIDTH;
}
return s;
}, [
ignoreControlsSpacing,
isFullscreen,
hideWindowControls,
interfaceScale,
size,
style,
useNativeTitlebar,
osType,
]);
return (
<div
data-tauri-drag-region
style={finalStyle}
className={classNames(
className,
'pt-[1px]', // Make up for bottom border
'select-none relative',
'w-full border-b border-border-subtle min-w-0',
)}
>
{/* NOTE: This needs display:grid or else the element shrinks (even though scrollable) */}
<div
data-tauri-drag-region
className={classNames(
'pointer-events-none h-full w-full overflow-x-auto hide-scrollbars grid',
'px-1', // Give it some space on either end for focus outlines
)}
>
{children}
</div>
{!hideControls && !useNativeTitlebar && (
<WindowControls
onlyX={onlyXWindowControl}
osType={osType}
hideWindowControls={hideWindowControls}
useNativeTitlebar={useNativeTitlebar}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,90 @@
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow';
import classNames from 'classnames';
import { useState } from 'react';
import { WINDOW_CONTROLS_WIDTH } from '../lib/constants';
import { Button } from './Button';
interface Props {
className?: string;
onlyX?: boolean;
osType: string;
hideWindowControls: boolean;
useNativeTitlebar: boolean;
}
export function WindowControls({ className, onlyX, osType, hideWindowControls, useNativeTitlebar }: Props) {
const [maximized, setMaximized] = useState<boolean>(false);
// Never show controls on macOS or if hideWindowControls is true
if (osType === 'macos' || hideWindowControls || useNativeTitlebar) {
return null;
}
return (
<div
className={classNames(className, 'ml-4 absolute right-0 top-0 bottom-0 flex items-center justify-end')}
style={{ width: WINDOW_CONTROLS_WIDTH }}
data-tauri-drag-region
>
{!onlyX && (
<>
<Button
className="!h-full px-4 text-text-subtle hocus:text hocus:bg-surface-highlight rounded-none"
color="custom"
onClick={() => getCurrentWebviewWindow().minimize()}
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<title>Minimize</title>
<path fill="currentColor" d="M14 8v1H3V8z" />
</svg>
</Button>
<Button
className="!h-full px-4 text-text-subtle hocus:text hocus:bg-surface-highlight rounded-none"
color="custom"
onClick={async () => {
const w = getCurrentWebviewWindow();
const isMaximized = await w.isMaximized();
if (isMaximized) {
await w.unmaximize();
setMaximized(false);
} else {
await w.maximize();
setMaximized(true);
}
}}
>
{maximized ? (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<title>Unmaximize</title>
<g fill="currentColor">
<path d="M3 5v9h9V5zm8 8H4V6h7z" />
<path fillRule="evenodd" d="M5 5h1V4h7v7h-1v1h2V3H5z" clipRule="evenodd" />
</g>
</svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<title>Maximize</title>
<path fill="currentColor" d="M3 3v10h10V3zm9 9H4V4h8z" />
</svg>
)}
</Button>
</>
)}
<Button
color="custom"
className="!h-full px-4 text-text-subtle rounded-none hocus:bg-danger hocus:text-text"
onClick={() => getCurrentWebviewWindow().close()}
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<title>Close</title>
<path
fill="currentColor"
fillRule="evenodd"
d="m7.116 8l-4.558 4.558l.884.884L8 8.884l4.558 4.558l.884-.884L8.884 8l4.558-4.558l-.884-.884L8 7.116L3.442 2.558l-.884.884z"
clipRule="evenodd"
/>
</svg>
</Button>
</div>
);
}

View File

@@ -0,0 +1,12 @@
import { debounce } from '@yaakapp-internal/lib';
import type { Dispatch, SetStateAction } from 'react';
import { useMemo, useState } from 'react';
export function useDebouncedState<T>(
defaultValue: T,
delay = 500,
): [T, Dispatch<SetStateAction<T>>, Dispatch<SetStateAction<T>>] {
const [state, setState] = useState<T>(defaultValue);
const debouncedSetState = useMemo(() => debounce(setState, delay), [delay]);
return [state, debouncedSetState, setState];
}

View File

@@ -0,0 +1,8 @@
import { useEffect } from 'react';
import { useDebouncedState } from './useDebouncedState';
export function useDebouncedValue<T>(value: T, delay = 500) {
const [state, setState] = useDebouncedState<T>(value, delay);
useEffect(() => setState(value), [setState, value]);
return state;
}

View File

@@ -0,0 +1,22 @@
import { useQuery } from '@tanstack/react-query';
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow';
import { useWindowSize } from 'react-use';
import { useDebouncedValue } from './useDebouncedValue';
export function useIsFullscreen() {
const windowSize = useWindowSize();
const debouncedWindowWidth = useDebouncedValue(windowSize.width);
// NOTE: Fullscreen state isn't updated right after resize event on Mac (needs to wait for animation) so
// we'll wait for a bit using the debounced window size. Hopefully Tauri eventually adds a way to listen
// for fullscreen change events.
return (
useQuery({
queryKey: ['is_fullscreen', debouncedWindowWidth],
queryFn: async () => {
return getCurrentWebviewWindow().isFullscreen();
},
}).data ?? false
);
}

View File

@@ -1,2 +1,8 @@
export { Button } from "./components/Button";
export type { ButtonProps } from "./components/Button";
export { HeaderSize } from "./components/HeaderSize";
export { WindowControls } from "./components/WindowControls";
export { useIsFullscreen } from "./hooks/useIsFullscreen";
export { useDebouncedValue } from "./hooks/useDebouncedValue";
export { useDebouncedState } from "./hooks/useDebouncedState";
export { HEADER_SIZE_MD, HEADER_SIZE_LG, WINDOW_CONTROLS_WIDTH } from "./lib/constants";

View File

@@ -0,0 +1,4 @@
export const HEADER_SIZE_MD = '27px';
export const HEADER_SIZE_LG = '40px';
export const WINDOW_CONTROLS_WIDTH = '10.5rem';