mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-14 10:17:55 +00:00
theme switch fox
This commit is contained in:
1 parent
4e03bc0388
commit
a63234e7d9
6 files changed
+114
-41
No files matched your search
@@ -0,0 +1,66 @@
|
||||
import * as React from 'react';
|
||||
|
||||
// Tested on iPhone (Safari 26, Low Power Mode too). Bottom edge: in the app
|
||||
// 6px works and 4px doesn't (4px was enough on a bare test page, 2px never).
|
||||
// Top edge: 16px works, 6px doesn't. One painted frame is enough; a 16ms
|
||||
// timer isn't, because it can fire before a paint (Low Power Mode drops the
|
||||
// refresh rate), so each strip lives for one frame.
|
||||
|
||||
/**
|
||||
* Shows a page-coloured strip on one edge for a single painted frame. At most
|
||||
* one strip per edge at a time: every useDarkTheme consumer applies a theme
|
||||
* change, and one strip is enough.
|
||||
*/
|
||||
function flashStrip(edge: 'top' | 'bottom', className: string): void {
|
||||
const slot = `${edge}-tint-reset`;
|
||||
if (document.querySelector(`[data-slot="${slot}"]`)) return;
|
||||
const strip = document.createElement('div');
|
||||
strip.dataset.slot = slot;
|
||||
strip.setAttribute('aria-hidden', 'true');
|
||||
strip.className = className;
|
||||
document.body.appendChild(strip);
|
||||
// The first frame paints the strip; remove it on the next.
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => strip.remove()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes iOS Safari re-colour its bottom bar from the page.
|
||||
*
|
||||
* Safari 26 takes the bottom bar's colour from a fixed element that appears on
|
||||
* the bottom edge (a bottom sheet turns it `bg-card`) and doesn't change it
|
||||
* back when that element goes away, or when the page changes colour (a theme
|
||||
* switch). A page-coloured strip that appears briefly on the edge makes Safari
|
||||
* sample again. The strip is 6px, no taller than the gap under the composer,
|
||||
* so it covers only background of its own colour. It has to be on top: a
|
||||
* strip behind the content, transparent or at `opacity-0` isn't sampled.
|
||||
*/
|
||||
function resetBottomTint(): void {
|
||||
flashStrip(
|
||||
'bottom',
|
||||
'bg-background pointer-events-none fixed inset-x-0 bottom-0 z-50 h-1.5',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes iOS Safari re-colour its top bar from the page, the same way as
|
||||
* `resetBottomTint`. Needed after a theme switch, when the top bar keeps the
|
||||
* old theme's colour. The strip is 16px, inside the phone top bar, which has
|
||||
* the same background.
|
||||
*/
|
||||
function resetTopTint(): void {
|
||||
flashStrip(
|
||||
'top',
|
||||
'bg-background pointer-events-none fixed inset-x-0 top-0 z-50 h-4',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders nothing; resets Safari's bottom bar when it unmounts. Put it inside
|
||||
* a bottom sheet's Radix Content, which unmounts after its exit animation.
|
||||
*/
|
||||
function BottomTintReset(): null {
|
||||
React.useEffect(() => resetBottomTint, []);
|
||||
return null;
|
||||
}
|
||||
|
||||
export { BottomTintReset, resetBottomTint, resetTopTint };
|
||||
@@ -1,39 +0,0 @@
|
||||
import * as React from 'react';
|
||||
|
||||
// Tested on iPhone (Safari 26, Low Power Mode too): in the app 6px works and
|
||||
// 4px doesn't (4px was enough on a bare test page, 2px never). One painted
|
||||
// frame is enough; a 16ms timer isn't, because it can fire before a paint
|
||||
// (Low Power Mode drops the refresh rate), so the strip lives for one frame.
|
||||
|
||||
/**
|
||||
* Makes iOS Safari re-colour its bottom bar from the page.
|
||||
*
|
||||
* Safari 26 takes the bottom bar's colour from a fixed element that appears on
|
||||
* the bottom edge (a bottom sheet turns it `bg-card`) and doesn't change it
|
||||
* back when that element goes away, or when an existing element changes
|
||||
* colour. A page-coloured strip that appears briefly on the edge makes Safari
|
||||
* sample again. The strip is 6px, no taller than the gap under the composer,
|
||||
* so it covers only background of its own colour. It has to be on top: a
|
||||
* strip behind the content, transparent or at `opacity-0` isn't sampled.
|
||||
*/
|
||||
function resetBottomTint(): void {
|
||||
const strip = document.createElement('div');
|
||||
strip.dataset.slot = 'bottom-tint-reset';
|
||||
strip.setAttribute('aria-hidden', 'true');
|
||||
strip.className =
|
||||
'bg-background pointer-events-none fixed inset-x-0 bottom-0 z-50 h-1.5';
|
||||
document.body.appendChild(strip);
|
||||
// The first frame paints the strip; remove it on the next.
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => strip.remove()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders nothing; resets Safari's bottom bar when it unmounts. Put it inside
|
||||
* a bottom sheet's Radix Content, which unmounts after its exit animation.
|
||||
*/
|
||||
function BottomTintReset(): null {
|
||||
React.useEffect(() => resetBottomTint, []);
|
||||
return null;
|
||||
}
|
||||
|
||||
export { BottomTintReset, resetBottomTint };
|
||||
@@ -2,7 +2,7 @@ import { XIcon } from 'lucide-react';
|
||||
import { Dialog as DialogPrimitive, VisuallyHidden } from 'radix-ui';
|
||||
import * as React from 'react';
|
||||
|
||||
import { BottomTintReset } from '@/components/ui/bottom-tint-reset';
|
||||
import { BottomTintReset } from '@/components/ui/bar-tint-reset';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Dialog as SheetPrimitive } from 'radix-ui';
|
||||
import { XIcon } from 'lucide-react';
|
||||
import { cva } from 'class-variance-authority';
|
||||
|
||||
import { BottomTintReset } from '@/components/ui/bottom-tint-reset';
|
||||
import { BottomTintReset } from '@/components/ui/bar-tint-reset';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn, overlayScrim } from '@/lib/utils';
|
||||
import { useFocusReturn } from '@/components/ui/use-focus-return';
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState, RefObject } from 'react';
|
||||
|
||||
import { resetBottomTint, resetTopTint } from '@/components/ui/bar-tint-reset';
|
||||
|
||||
export function useOutsideAlerter<T extends HTMLElement>(
|
||||
ref: RefObject<T | null>,
|
||||
handler: () => void,
|
||||
@@ -118,6 +120,8 @@ export function useDarkTheme() {
|
||||
|
||||
const [isDarkTheme, setIsDarkTheme] = useState<boolean>(getInitialTheme());
|
||||
const [componentMounted, setComponentMounted] = useState(false);
|
||||
// The theme applied by this hook's last run; null before the first one.
|
||||
const appliedTheme = useRef<boolean | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
@@ -157,6 +161,13 @@ export function useDarkTheme() {
|
||||
m.removeAttribute('media');
|
||||
m.setAttribute('content', color);
|
||||
});
|
||||
// Safari keeps both bars in the old theme's colour until a fixed element
|
||||
// appears on their edge. Only on a change: on load they already match.
|
||||
if (appliedTheme.current !== null && appliedTheme.current !== isDarkTheme) {
|
||||
resetBottomTint();
|
||||
resetTopTint();
|
||||
}
|
||||
appliedTheme.current = isDarkTheme;
|
||||
|
||||
setComponentMounted(true);
|
||||
}, [isDarkTheme]);
|
||||
|
||||
@@ -60,4 +60,39 @@ describe('useDarkTheme', () => {
|
||||
|
||||
await act(async () => root.unmount());
|
||||
});
|
||||
|
||||
it('resets both Safari bars once per theme change, not on load', async () => {
|
||||
const strips = (edge: 'top' | 'bottom') =>
|
||||
document.querySelectorAll(`[data-slot="${edge}-tint-reset"]`).length;
|
||||
let toggle: (() => void) | undefined;
|
||||
|
||||
function Settings() {
|
||||
const [, toggleTheme] = useDarkTheme();
|
||||
toggle = toggleTheme;
|
||||
return null;
|
||||
}
|
||||
function Logo() {
|
||||
useDarkTheme();
|
||||
return null;
|
||||
}
|
||||
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<>
|
||||
<Settings />
|
||||
<Logo />
|
||||
</>,
|
||||
);
|
||||
});
|
||||
expect(strips('bottom')).toBe(0);
|
||||
expect(strips('top')).toBe(0);
|
||||
|
||||
// Both consumers apply the new theme; one strip is enough.
|
||||
await act(async () => toggle?.());
|
||||
expect(strips('bottom')).toBe(1);
|
||||
expect(strips('top')).toBe(1);
|
||||
|
||||
await act(async () => root.unmount());
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user