Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions apps/site/src/__tests__/app.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ test('the hero shows the CLI install command for the detected platform, with a c
for (const code of codes) {
const onMac = hero(render(mac, code));
expect(onMac).toMatch(
/<div class="command-box"><code>brew install junjuny0227\/tap\/repot<\/code><button/,
/<div class="command-box"><div class="command-text"><code>brew install junjuny0227\/tap\/repot<\/code><\/div><button/,
);
const onWindows = hero(render(windows, code));
expect(onWindows).toContain('scoop install repot/repot');
Expand Down Expand Up @@ -108,7 +108,11 @@ test('every command on the page is copyable', () => {
const html = render(mac);
expect(html).not.toContain('<pre');
// Hero CLI, install CLI and the macOS quarantine command.
expect(html.match(/<div class="command-box"><code>[^<]+<\/code><button/g)).toHaveLength(3);
expect(
html.match(
/<div class="command-box"><div class="command-text"><code>[^<]+<\/code><\/div><button/g,
),
).toHaveLength(3);
});

test('every page links to every language, marks its own, and asks for translation help', () => {
Expand Down
32 changes: 32 additions & 0 deletions apps/site/src/__tests__/copy.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { renderToStaticMarkup } from 'react-dom/server';
import { expect, test } from 'vitest';
import { ContentContext, content } from '../content';
import { CopyCommand, type CopyStatus, copyCommand } from '../sections/CopyCommand';
import { scrollState } from '../sections/ScrollingCode';

const BREW = 'brew install junjuny0227/tap/repot';

Expand Down Expand Up @@ -46,3 +47,34 @@ test('failed: the live region explains what to do instead, in the page language'
expect(html).toContain('Could not copy. Select the command and copy it manually.');
expect(render('failed', 'ko')).toContain('복사하지 못했습니다.');
});

test('a scrolled command fades only the ends that hide text, and places the thumb', () => {
// Fits: nothing hidden, no scrollbar.
expect(scrollState(0, 300, 300)).toEqual({
start: false,
end: false,
thumb: { offset: 0, size: 1 },
});
expect(scrollState(0, 300.5, 300).thumb.size).toBe(1);
// At the start of a long command: only the end is hidden.
expect(scrollState(0, 400, 200)).toEqual({
start: false,
end: true,
thumb: { offset: 0, size: 0.5 },
});
// In the middle: both ends.
expect(scrollState(100, 400, 200)).toEqual({
start: true,
end: true,
thumb: { offset: 0.25, size: 0.5 },
});
// At the end, including a sub-pixel remainder: only the start.
expect(scrollState(199.5, 400, 200)).toMatchObject({ start: true, end: false });
});

test('the server markup has no fade or scrollbar until the browser measures the command', () => {
const html = render('idle');
expect(html).toContain('<div class="command-text"><code>');
expect(html).not.toContain('data-hidden');
expect(html).not.toContain('command-scrollbar');
});
3 changes: 2 additions & 1 deletion apps/site/src/sections/CopyCommand.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react';
import { useContent } from '../content';
import { Checkmark, CopyGlyph } from '../icons';
import { ScrollingCode } from './ScrollingCode';

export type CopyStatus = 'idle' | 'copied' | 'failed';

Expand Down Expand Up @@ -42,7 +43,7 @@ export function CopyCommand({
return (
<div className={`command ${status}`}>
<div className="command-box">
<code>{command}</code>
<ScrollingCode>{command}</ScrollingCode>
<button
type="button"
className="button"
Expand Down
97 changes: 97 additions & 0 deletions apps/site/src/sections/ScrollingCode.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { type PointerEvent, useEffect, useRef, useState } from 'react';

export type ScrollState = {
/** Text is hidden past the left end, so that end fades out. */
start: boolean;
/** Text is hidden past the right end. */
end: boolean;
/** The thumb's left edge and width, as fractions of the track. */
thumb: { offset: number; size: number };
};

const FITS: ScrollState = { start: false, end: false, thumb: { offset: 0, size: 1 } };

/** What a one-line command scrolled to `scrollLeft` hides, and where its scrollbar thumb sits. */
export function scrollState(
scrollLeft: number,
scrollWidth: number,
clientWidth: number,
): ScrollState {
const range = scrollWidth - clientWidth;
// 1px slack: fractional widths can leave a sub-pixel remainder at either end.
if (range <= 1) return FITS;
return {
start: scrollLeft > 1,
end: range - scrollLeft > 1,
thumb: { offset: scrollLeft / scrollWidth, size: clientWidth / scrollWidth },
};
}

/**
* A command on one line that scrolls sideways. Each end that hides text fades out, and a thin
* overlay scrollbar appears while the pointer is over the command box (see `.command-box` in
* site.css). The native scrollbar is hidden: a classic one would take height only in boxes
* whose command overflows. The thumb can be dragged; trackpads and Shift-wheel scroll as usual.
*/
export function ScrollingCode({ children }: { children: string }) {
const scroller = useRef<HTMLElement>(null);
const [state, setState] = useState<ScrollState>(FITS);
const measure = () => {
const el = scroller.current;
if (el) setState(scrollState(el.scrollLeft, el.scrollWidth, el.clientWidth));
};
// Re-measure when the box changes width (window resize, font load).
useEffect(() => {
const el = scroller.current;
if (!el || typeof ResizeObserver === 'undefined') return;
const observer = new ResizeObserver(measure);
observer.observe(el);
return () => observer.disconnect();
}, []);

const drag = useRef<{ x: number; scrollLeft: number } | null>(null);
const onPointerDown = (e: PointerEvent<HTMLDivElement>) => {
const el = scroller.current;
if (!el) return;
e.currentTarget.setPointerCapture(e.pointerId);
drag.current = { x: e.clientX, scrollLeft: el.scrollLeft };
};
const onPointerMove = (e: PointerEvent<HTMLDivElement>) => {
const el = scroller.current;
if (!el || !drag.current) return;
// The thumb moves clientWidth/scrollWidth as fast as the text.
el.scrollLeft =
drag.current.scrollLeft + ((e.clientX - drag.current.x) * el.scrollWidth) / el.clientWidth;
};
const endDrag = () => {
drag.current = null;
};

const overflows = state.thumb.size < 1;
return (
<div
className="command-text"
data-hidden-start={state.start || undefined}
data-hidden-end={state.end || undefined}
>
<code ref={scroller} onScroll={measure}>
{children}
</code>
{overflows && (
<div className="command-scrollbar" aria-hidden="true">
<div
className="command-thumb"
style={{
left: `${state.thumb.offset * 100}%`,
width: `${state.thumb.size * 100}%`,
}}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
/>
</div>
)}
</div>
);
}
81 changes: 76 additions & 5 deletions apps/site/src/styles/site.css
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,10 @@ button.button {
max-width: 520px;
margin-top: 24px;
}
/* The command scrolls inside its box, so the column must not grow to the command's width. */
.hero .downloads {
width: 100%;
}
.hero-cli .download-title {
margin-bottom: 8px;
}
Expand Down Expand Up @@ -884,28 +888,95 @@ h3 {
white-space: nowrap;
}

/* Copyable commands: a code block with a push button. Long commands wrap instead of being cut. */
/* Copyable commands: a code block with a push button. A long command stays on one line and
scrolls sideways, so a path is never split mid-word; the copy button stays in view. */
.command {
text-align: left;
}
.command-box {
display: flex;
align-items: flex-start;
align-items: center;
gap: 8px;
padding: 8px 8px 8px 12px;
border-radius: 8px;
background-color: var(--code);
box-shadow: inset 0 0 0 1px var(--separator);
}
.command-box code {
/* A long command scrolls sideways (ScrollingCode). Each end with hidden text fades into the
box, and a thin overlay scrollbar shows only while the pointer is over the box or it has
focus. The native scrollbar is hidden: a classic one would make only overflowing boxes taller. */
.command-text {
position: relative;
flex: 1;
min-width: 0;
}
.command-box code {
display: block;
padding: 4px 0;
color: var(--label);
font-size: 13px;
line-height: 20px;
white-space: pre-wrap;
overflow-wrap: anywhere;
white-space: pre;
overflow-x: auto;
scrollbar-width: none;
}
.command-box code::-webkit-scrollbar {
display: none;
}
.command-scrollbar {
position: absolute;
right: 0;
bottom: -5px;
left: 0;
height: 4px;
opacity: 0;
}
.command-box:hover .command-scrollbar,
.command-box:focus-within .command-scrollbar {
opacity: 1;
}
.command-thumb {
position: absolute;
top: 0;
bottom: 0;
border-radius: 2px;
background-color: var(--label-3);
touch-action: none;
}
/* A taller invisible grab area than the 4px bar. */
.command-thumb::after {
content: "";
position: absolute;
inset: -6px 0;
}
.command-text::before,
.command-text::after {
content: "";
position: absolute;
top: 0;
bottom: 0;
width: 32px;
pointer-events: none;
opacity: 0;
}
.command-text::before {
left: 0;
background: linear-gradient(to right, var(--code), transparent);
}
.command-text::after {
right: 0;
background: linear-gradient(to left, var(--code), transparent);
}
.command-text[data-hidden-start]::before,
.command-text[data-hidden-end]::after {
opacity: 1;
}
@media (prefers-reduced-motion: no-preference) {
.command-text::before,
.command-text::after,
.command-scrollbar {
transition: opacity 150ms ease;
}
}
.command-box .button {
flex: none;
Expand Down
Loading