mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
Merge pull request #1199 from MBombeck/feat/strip-full-height
feat(shell): full-height docked strips, magnifier search, g p for the day
This commit is contained in:
31 files changed
+633
-242
No files matched your search
+59
-18
@@ -21,9 +21,12 @@ import { mockDay } from "./setup/day-mock";
|
||||
* panel left of the strips, a second click shuts it; the choice
|
||||
* survives a reload. The panel is flush with the strips and runs from
|
||||
* the top of the window to the bottom; the top bar ends at its left
|
||||
* edge and the panel's header border is the top bar's. Focus moves
|
||||
* between the fold control and the strip, and Escape inside the open
|
||||
* panel folds it.
|
||||
* edge and the panel's header border is the top bar's. Both strips run
|
||||
* the full height of the window, from its top edge, and the search
|
||||
* magnifier ends the top bar in front of them, never covered. The strip
|
||||
* is the docked panel's only fold control (the header has none); focus
|
||||
* moves between the strip and the panel's heading, and Escape inside
|
||||
* the open panel folds it.
|
||||
* 2. New conversation is a round 56 px button in the conversation column,
|
||||
* never over the panel, never over the composer's field, and absent
|
||||
* from the panel's header.
|
||||
@@ -218,6 +221,8 @@ async function mockCoach(page: Page): Promise<CoachMock> {
|
||||
const toggle = (page: Page) => page.locator('[data-slot="coach-panel-toggle"]');
|
||||
const collapse = (page: Page) =>
|
||||
page.locator('[data-slot="coach-panel-collapse"]');
|
||||
const heading = (page: Page) =>
|
||||
page.locator('[data-slot="coach-panel-heading"]');
|
||||
const expand = (page: Page) =>
|
||||
page.locator('[data-slot="coach-panel-strip-toggle"]');
|
||||
const strip = (page: Page) => page.locator('[data-slot="coach-panel-strip"]');
|
||||
@@ -257,7 +262,9 @@ async function expectNoSidewaysScroll(page: Page) {
|
||||
* right edge (no scrollbar gutter beside them). The panel runs from the top
|
||||
* of the window to the bottom, the top bar ends at its left edge, an open
|
||||
* panel's header row has the top bar's bottom border, and its own border
|
||||
* is the left one only. Both strips start below the top bar's band.
|
||||
* is the left one only. Both strips run the full height of the window, and
|
||||
* the search magnifier at the end of the top bar sits in front of them,
|
||||
* whole and on top.
|
||||
*/
|
||||
async function expectPanelFlush(page: Page, width: number, height: number) {
|
||||
const geometry = await page.evaluate(() => {
|
||||
@@ -283,6 +290,18 @@ async function expectPanelFlush(page: Page, width: number, height: number) {
|
||||
list: box('[data-slot="coach-panel-strip"]')!,
|
||||
listButton: box('[data-slot="coach-panel-strip-toggle"]')!,
|
||||
day: box('[data-slot="day-strip"]')!,
|
||||
dayButton: box('[data-slot="day-strip-toggle"]')!,
|
||||
search: box('[data-slot="command-palette-trigger"]')!,
|
||||
searchOnTop: (() => {
|
||||
const r = document
|
||||
.querySelector('[data-slot="command-palette-trigger"]')!
|
||||
.getBoundingClientRect();
|
||||
return (
|
||||
document
|
||||
.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2)
|
||||
?.closest('[data-slot="command-palette-trigger"]') != null
|
||||
);
|
||||
})(),
|
||||
dayDock: box("#day-docked-panel")!,
|
||||
gutter: main.offsetWidth - main.clientWidth,
|
||||
borders: style
|
||||
@@ -305,8 +324,17 @@ async function expectPanelFlush(page: Page, width: number, height: number) {
|
||||
expect(geometry.panel.top).toBe(0);
|
||||
expect(geometry.panel.bottom).toBe(height);
|
||||
expect(geometry.bar.right).toBe(geometry.panel.left);
|
||||
// The strips start below the band; the palette's corner stays the bar's.
|
||||
expect(geometry.listButton.top).toBe(geometry.bar.bottom);
|
||||
// The strips run the full height, from the top edge of the window.
|
||||
for (const button of [geometry.listButton, geometry.dayButton]) {
|
||||
expect(button.top).toBe(0);
|
||||
expect(button.bottom).toBe(height);
|
||||
}
|
||||
// The magnifier ends the top bar, left of the strips, whole and on top.
|
||||
expect(geometry.search.top).toBeGreaterThanOrEqual(0);
|
||||
expect(geometry.search.bottom).toBeLessThanOrEqual(geometry.bar.bottom);
|
||||
expect(geometry.search.right).toBeLessThanOrEqual(geometry.bar.right);
|
||||
expect(geometry.search.right).toBeLessThanOrEqual(geometry.list.left);
|
||||
expect(geometry.searchOnTop).toBe(true);
|
||||
if (geometry.header !== null) {
|
||||
expect(geometry.header).toBe(geometry.bar.bottom);
|
||||
expect(geometry.borders).toEqual(["0px", "0px", "0px", "1px"]);
|
||||
@@ -364,7 +392,6 @@ async function panelControlHeights(page: Page) {
|
||||
.boundingBox())!.height,
|
||||
);
|
||||
return {
|
||||
collapse: await height("coach-panel-collapse"),
|
||||
plans: await height("coach-panel-plans"),
|
||||
gear: await height("coach-settings"),
|
||||
row: await height("coach-history-select"),
|
||||
@@ -449,10 +476,11 @@ test.describe("Coach page frame", () => {
|
||||
// First visit: open.
|
||||
await expect(panel(page)).toHaveAttribute("data-state", "open");
|
||||
await waitForWidth(panel(page), 288);
|
||||
// Docked, the fold control lives in the panel; the top bar has none.
|
||||
// Docked, the strip is the one fold control: the top bar and the
|
||||
// panel's header carry none.
|
||||
await expect(toggle(page)).toHaveCount(0);
|
||||
await expect(collapse(page)).toHaveAttribute("aria-expanded", "true");
|
||||
await expect(collapse(page)).toHaveAttribute(
|
||||
await expect(collapse(page)).toHaveCount(0);
|
||||
await expect(expand(page)).toHaveAttribute(
|
||||
"aria-controls",
|
||||
"coach-conversations-panel",
|
||||
);
|
||||
@@ -480,7 +508,7 @@ test.describe("Coach page frame", () => {
|
||||
|
||||
// Folded: the panel slides to nothing, the strip takes the focus, and
|
||||
// the top bar ends at the strips.
|
||||
await collapse(page).click();
|
||||
await expand(page).click();
|
||||
await expect(panel(page)).toHaveAttribute("data-state", "closed");
|
||||
await waitForWidth(panel(page), 0);
|
||||
expect(await stripBox(strip(page))).toEqual(stripOpen);
|
||||
@@ -522,12 +550,17 @@ test.describe("Coach page frame", () => {
|
||||
await expect(expand(page)).toBeFocused();
|
||||
expect(await stripBox(strip(page))).toEqual(stripOpen);
|
||||
|
||||
// Opened from the keyboard, focus lands on the fold control; Escape
|
||||
// Opened from the keyboard, focus lands on the panel's heading; Escape
|
||||
// from inside folds it again and focus goes back to the strip.
|
||||
await expand(page).focus();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(panel(page)).toHaveAttribute("data-state", "open");
|
||||
await expect(collapse(page)).toBeFocused();
|
||||
await expect(heading(page)).toBeFocused();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(panel(page)).toHaveAttribute("data-state", "closed");
|
||||
await expect(expand(page)).toBeFocused();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(panel(page)).toHaveAttribute("data-state", "open");
|
||||
await panel(page)
|
||||
.locator('[data-slot="coach-history-select"]')
|
||||
.first()
|
||||
@@ -572,14 +605,14 @@ test.describe("Coach page frame", () => {
|
||||
await shot(page, testInfo, "coach-frame-1440-day");
|
||||
|
||||
// Opening the conversations closes the day; its strip keeps the day,
|
||||
// focus goes to the conversations' fold control, the day is out of the
|
||||
// focus goes to the conversations' heading, the day is out of the
|
||||
// URL, and the conversation stays the same.
|
||||
await expand(page).click();
|
||||
await expect(panel(page)).toHaveAttribute("data-state", "open");
|
||||
await expect(dayPanel(page)).toHaveCount(0);
|
||||
await expect(dayStrip(page)).toHaveAttribute("data-day", day);
|
||||
await expect(dayStrip(page)).toHaveAttribute("data-state", "closed");
|
||||
await expect(collapse(page)).toBeFocused();
|
||||
await expect(heading(page)).toBeFocused();
|
||||
await expect(page).toHaveURL(/\/coach\?c=frame-bp$/);
|
||||
await expectNewChatFab(page);
|
||||
await shot(page, testInfo, "coach-frame-1440-list");
|
||||
@@ -627,7 +660,7 @@ test.describe("Coach page frame", () => {
|
||||
await shot(page, testInfo, "coach-frame-1920-both");
|
||||
|
||||
// Folding one leaves the other alone.
|
||||
await collapse(page).click();
|
||||
await expand(page).click();
|
||||
await expect(panel(page)).toHaveAttribute("data-state", "closed");
|
||||
await expect(dayPanel(page)).toBeVisible();
|
||||
await expand(page).click();
|
||||
@@ -642,7 +675,6 @@ test.describe("Coach page frame", () => {
|
||||
await mockCoach(page);
|
||||
await openCoach(page);
|
||||
expect(await panelControlHeights(page)).toEqual({
|
||||
collapse: 28,
|
||||
plans: 28,
|
||||
gear: 28,
|
||||
row: 36,
|
||||
@@ -662,7 +694,6 @@ test.describe("Coach page frame", () => {
|
||||
await openCoach(page);
|
||||
await expect(panel(page)).toHaveAttribute("data-state", "open");
|
||||
expect(await panelControlHeights(page)).toEqual({
|
||||
collapse: 44,
|
||||
plans: 44,
|
||||
gear: 44,
|
||||
row: 44,
|
||||
@@ -935,6 +966,16 @@ test.describe("Coach page frame", () => {
|
||||
await title.evaluate((el) => el.scrollWidth <= el.clientWidth),
|
||||
"the sheet title is not truncated",
|
||||
).toBe(true);
|
||||
// Without a strip, the sheet keeps a clear close button and no fold.
|
||||
await expect(
|
||||
sheet.locator('[data-slot="coach-panel-close"]'),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
sheet.locator('[data-slot="coach-panel-close"]'),
|
||||
).toHaveAttribute("aria-label", "Close");
|
||||
await expect(
|
||||
sheet.locator('[data-slot="coach-panel-collapse"]'),
|
||||
).toHaveCount(0);
|
||||
await shot(page, testInfo, "coach-frame-390-panel");
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
+23
-16
@@ -12,13 +12,14 @@ import { expect, test } from "./setup/test";
|
||||
* - a module's entry is a switch: Enter sends the module write and the
|
||||
* palette stays open (the write is answered by a route mock, so the
|
||||
* shared account's modules never move);
|
||||
* - the top bar carries the search field on a wide desktop and a
|
||||
* magnifier on a phone, which opens a full-height sheet with the field
|
||||
* focused;
|
||||
* - the top bar carries a magnifier at every width, named "Search",
|
||||
* with a tooltip that gives the shortcut; on a phone it opens a
|
||||
* full-height sheet with the field focused;
|
||||
* - the open palette passes axe.
|
||||
*
|
||||
* Stable hooks only: `command-palette` (+ `data-variant`),
|
||||
* `command-palette-input`, `command-palette-trigger` (+ `data-variant`),
|
||||
* `command-palette-input`, `command-palette-trigger`,
|
||||
* `command-palette-tooltip`,
|
||||
* options by `data-entry`.
|
||||
*/
|
||||
|
||||
@@ -109,20 +110,27 @@ test.describe("command palette", () => {
|
||||
await expect(input(page)).toHaveValue("timeline");
|
||||
});
|
||||
|
||||
test("the top bar: a search field on a wide desktop", async ({ page }) => {
|
||||
test("the top bar: a magnifier on a wide desktop, with the shortcut in its tooltip", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await page.goto("/");
|
||||
await shellReady(page);
|
||||
const field = page.locator(
|
||||
'[data-slot="command-palette-trigger"][data-variant="field"]',
|
||||
const magnifier = page.locator('[data-slot="command-palette-trigger"]');
|
||||
await expect(magnifier).toHaveCount(1);
|
||||
await expect(magnifier).toBeVisible();
|
||||
await expect(magnifier).toHaveAttribute("aria-label", "Search");
|
||||
await expect(magnifier).toHaveAttribute(
|
||||
"aria-keyshortcuts",
|
||||
/^(Meta|Control)\+K$/,
|
||||
);
|
||||
await expect(field).toBeVisible();
|
||||
// An icon, not a field: no visible words in the button.
|
||||
expect((await magnifier.innerText()).trim()).toBe("");
|
||||
await magnifier.hover();
|
||||
await expect(
|
||||
page.locator(
|
||||
'[data-slot="command-palette-trigger"][data-variant="icon"]',
|
||||
),
|
||||
).toBeHidden();
|
||||
await field.click();
|
||||
page.locator('[data-slot="command-palette-tooltip"]').first(),
|
||||
).toHaveText(/^Search \((⌘K|Ctrl K)\)$/);
|
||||
await magnifier.click();
|
||||
await expect(palette(page)).toBeVisible();
|
||||
await expect(input(page)).toBeFocused();
|
||||
|
||||
@@ -142,10 +150,9 @@ test.describe("command palette", () => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto("/");
|
||||
await shellReady(page);
|
||||
const magnifier = page.locator(
|
||||
'[data-slot="command-palette-trigger"][data-variant="icon"]',
|
||||
);
|
||||
const magnifier = page.locator('[data-slot="command-palette-trigger"]');
|
||||
await expect(magnifier).toBeVisible();
|
||||
await expect(magnifier).toHaveAttribute("aria-label", "Search");
|
||||
await magnifier.click();
|
||||
await expect(palette(page)).toHaveAttribute("data-variant", "sheet");
|
||||
await expect(input(page)).toBeFocused();
|
||||
|
||||
+11
-3
@@ -245,8 +245,15 @@ test.describe("the dashboard and the list of all values", () => {
|
||||
.first();
|
||||
await expect(plot).toBeVisible({ timeout: 20_000 });
|
||||
await openFromPlot(page, plot, testInfo);
|
||||
// Closing leaves the dashboard on today.
|
||||
await page.locator('[data-slot="day-close"]').click();
|
||||
// Closing leaves the dashboard on today: a sheet from its close button,
|
||||
// the docked day from its strip, the only control it has.
|
||||
await page
|
||||
.locator(
|
||||
isPhone(testInfo)
|
||||
? '[data-slot="day-close"]'
|
||||
: '[data-slot="day-strip-toggle"]',
|
||||
)
|
||||
.click();
|
||||
await expect(panel(page)).toHaveCount(0);
|
||||
await expect(page).toHaveURL(/\/$/);
|
||||
});
|
||||
@@ -292,7 +299,8 @@ test.describe("the dashboard and the list of all values", () => {
|
||||
.toBeLessThanOrEqual(panelBox.x);
|
||||
|
||||
// Folded, the day keeps its strip; the launcher stays clear of it.
|
||||
await page.locator('[data-slot="day-close"]').click();
|
||||
await page.locator('[data-slot="day-strip-toggle"]').click();
|
||||
await expect(panel(page)).toHaveCount(0);
|
||||
const edge = (await page.locator('[data-slot="day-strip"]').boundingBox())!;
|
||||
await expect
|
||||
.poll(async () => {
|
||||
|
||||
+72
-5
@@ -21,8 +21,11 @@ import { MOBILE_ROUTES_ANALYTE } from "./setup/mobile-routes-fixture";
|
||||
* - `?day=` opens the layer on any page, Back and Escape close it, a date in
|
||||
* the future is dropped;
|
||||
* - stepping to the neighbouring days never adds history entries;
|
||||
* - docked, the day collapses to a narrow edge that brings it back on any
|
||||
* page; a sheet closes for good;
|
||||
* - docked, the day folds to its strip, which runs the full height of the
|
||||
* window right of the top bar, is the panel's only open and close
|
||||
* control and brings the day back on any page; the search magnifier at
|
||||
* the end of the top bar is never covered by it; a sheet closes for good
|
||||
* from its close button;
|
||||
* - the dashboard's today area offers no door; its charts below do.
|
||||
*
|
||||
* Stable data attributes only: `day-panel` (+ `data-shell`), `day-link`,
|
||||
@@ -175,12 +178,15 @@ test.describe("the day view", () => {
|
||||
await page.goto(`/mood?day=${day}`);
|
||||
await expect(panel(page)).toHaveAttribute("data-shell", "docked");
|
||||
|
||||
await page.locator('[data-slot="day-close"]').click();
|
||||
// Docked, the header has no fold button: the strip is the toggle.
|
||||
await expect(page.locator('[data-slot="day-close"]')).toHaveCount(0);
|
||||
const rail = page.locator('[data-slot="day-strip"]');
|
||||
const expand = page.locator('[data-slot="day-strip-toggle"]');
|
||||
await expect(expand).toHaveAttribute("aria-expanded", "true");
|
||||
await expand.click();
|
||||
await expect(panel(page)).toHaveCount(0);
|
||||
await expect(page).toHaveURL(/\/mood$/);
|
||||
// The strip stays, holding the day, and focus waits on it.
|
||||
const rail = page.locator('[data-slot="day-strip"]');
|
||||
const expand = page.locator('[data-slot="day-strip-toggle"]');
|
||||
await expect(rail).toHaveAttribute("data-day", day);
|
||||
await expect(rail).toHaveAttribute("data-state", "closed");
|
||||
await expect(expand).toBeFocused();
|
||||
@@ -305,6 +311,67 @@ test.describe("the day view", () => {
|
||||
await expect(page.locator('[data-slot="day-strip"]')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("the strip runs the full height; the search magnifier stays clear of it", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.skip(
|
||||
testInfo.project.name !== "chromium-desktop",
|
||||
"docked from 1280 px",
|
||||
);
|
||||
const day = isoDaysAgo(3);
|
||||
const strip = page.locator('[data-slot="day-strip"]');
|
||||
const search = page.locator('[data-slot="command-palette-trigger"]');
|
||||
const topBar = page.locator('[data-slot="top-bar"]');
|
||||
for (const width of [1280, 1440, 1920]) {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
for (const url of ["/mood", `/mood?day=${day}`]) {
|
||||
await page.goto(url);
|
||||
await expect(strip).toBeVisible();
|
||||
if (url.includes("day=")) {
|
||||
await expect(panel(page)).toHaveAttribute("data-shell", "docked");
|
||||
// The open panel runs the full height beside its strip.
|
||||
await expect
|
||||
.poll(async () => (await panel(page).boundingBox())?.width)
|
||||
.toBeGreaterThan(400);
|
||||
const p = (await panel(page).boundingBox())!;
|
||||
expect(p.y).toBe(0);
|
||||
expect(Math.round(p.y + p.height)).toBe(900);
|
||||
}
|
||||
const s = (await strip.boundingBox())!;
|
||||
// Top edge at the very top of the window, down to the bottom.
|
||||
expect(s.y).toBe(0);
|
||||
expect(Math.round(s.height)).toBe(900);
|
||||
expect(Math.round(s.x + s.width)).toBe(width);
|
||||
// The top bar ends left of the strips, so the magnifier sits fully
|
||||
// inside it, in front of them.
|
||||
const bar = (await topBar.boundingBox())!;
|
||||
expect(bar.x + bar.width).toBeLessThanOrEqual(s.x + 0.5);
|
||||
await expect(search).toBeVisible();
|
||||
const m = (await search.boundingBox())!;
|
||||
expect(m.x).toBeGreaterThanOrEqual(0);
|
||||
expect(m.y).toBeGreaterThanOrEqual(0);
|
||||
expect(m.x + m.width).toBeLessThanOrEqual(s.x);
|
||||
expect(m.y + m.height).toBeLessThanOrEqual(bar.y + bar.height);
|
||||
// Nothing paints over its centre: the hit test lands on it.
|
||||
const hit = await page.evaluate(
|
||||
([x, y]) =>
|
||||
document
|
||||
.elementFromPoint(x, y)
|
||||
?.closest('[data-slot="command-palette-trigger"]') != null,
|
||||
[m.x + m.width / 2, m.y + m.height / 2],
|
||||
);
|
||||
expect(hit).toBe(true);
|
||||
// No horizontal scroll anywhere on the page.
|
||||
const overflow = await page.evaluate(
|
||||
() =>
|
||||
document.documentElement.scrollWidth -
|
||||
document.documentElement.clientWidth,
|
||||
);
|
||||
expect(overflow).toBeLessThanOrEqual(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("a future or malformed ?day= is dropped without a word", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -15,7 +15,10 @@ import { mockTimeline } from "./utils/mock-timeline";
|
||||
* - `?` opens the list: a titled dialog that keeps focus inside and closes
|
||||
* on Escape; the account menu opens the same list;
|
||||
* - `n` opens the add menu;
|
||||
* - `[` and `]` step the open day, never past today.
|
||||
* - `[` and `]` step the open day, never past today;
|
||||
* - `g p` opens and closes the day like a click on its strip (focus into
|
||||
* the panel, back to the strip); below the docking width it opens the
|
||||
* sheet on the last day and closes it again.
|
||||
*
|
||||
* The module map is the account's real `/api/auth/me` with only `timeline`
|
||||
* rewritten, so the switch is never moved on a shared account. Desktop only:
|
||||
@@ -149,6 +152,9 @@ test.describe("keyboard shortcuts", () => {
|
||||
"g",
|
||||
"d",
|
||||
]);
|
||||
await expect(
|
||||
dialog.locator('[data-group="day"] [data-shortcut="day-toggle"] kbd'),
|
||||
).toHaveText(["g", "p"]);
|
||||
|
||||
// Focus trap: Tab and Shift+Tab never leave the dialog.
|
||||
for (const step of ["Tab", "Tab", "Tab", "Shift+Tab", "Shift+Tab"]) {
|
||||
@@ -213,4 +219,92 @@ test.describe("keyboard shortcuts", () => {
|
||||
await page.keyboard.press("]");
|
||||
await expect(page).toHaveURL(new RegExp(`[?&]day=${TODAY}`));
|
||||
});
|
||||
|
||||
test("g p opens and closes the docked day, focus like its strip", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await mockDay(page);
|
||||
await page.goto("/insights");
|
||||
await shellReady(page);
|
||||
const panel = page.locator('[data-slot="day-panel"]');
|
||||
const strip = page.locator('[data-slot="day-strip-toggle"]');
|
||||
await expect(strip).toHaveAttribute("aria-expanded", "false");
|
||||
await expect(panel).toHaveCount(0);
|
||||
|
||||
await page.keyboard.press("g");
|
||||
await page.keyboard.press("p");
|
||||
await expect(panel).toHaveAttribute("data-shell", "docked");
|
||||
await expect(page).toHaveURL(new RegExp(`[?&]day=${TODAY}`));
|
||||
await expect(strip).toHaveAttribute("aria-expanded", "true");
|
||||
// Into the panel, on its heading, the way the strip opens it.
|
||||
await expect(panel.locator("h2").first()).toBeFocused();
|
||||
|
||||
await page.keyboard.press("g");
|
||||
await page.keyboard.press("p");
|
||||
await expect(panel).toHaveCount(0);
|
||||
await expect(page).toHaveURL(/\/insights$/);
|
||||
await expect(strip).toHaveAttribute("aria-expanded", "false");
|
||||
// Back on the strip, which brings the day back.
|
||||
await expect(strip).toBeFocused();
|
||||
});
|
||||
|
||||
test("below the docking width g p opens the sheet on the last day and closes it", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 1024, height: 800 });
|
||||
await mockDay(page);
|
||||
const day = shiftDay(TODAY, -3);
|
||||
// Open and close a day once, so it is the one remembered.
|
||||
await page.goto(`/insights?day=${day}`);
|
||||
await shellReady(page);
|
||||
const panel = page.locator('[data-slot="day-panel"]');
|
||||
await expect(panel).toHaveAttribute("data-shell", "sheet");
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(panel).toHaveCount(0);
|
||||
await page.locator("#main-content").focus();
|
||||
|
||||
await page.keyboard.press("g");
|
||||
await page.keyboard.press("p");
|
||||
await expect(panel).toHaveAttribute("data-shell", "sheet");
|
||||
await expect(page.locator('[data-slot="day-view"]')).toHaveAttribute(
|
||||
"data-day",
|
||||
day,
|
||||
);
|
||||
// Over the sheet, the same keys close it.
|
||||
await page.keyboard.press("g");
|
||||
await page.keyboard.press("p");
|
||||
await expect(panel).toHaveCount(0);
|
||||
await expect(page).toHaveURL(/\/insights$/);
|
||||
});
|
||||
|
||||
test("g p stays out of a field and out of another dialog", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await mockDay(page);
|
||||
await page.goto("/insights");
|
||||
await shellReady(page);
|
||||
const panel = page.locator('[data-slot="day-panel"]');
|
||||
await expect(page.locator('[data-slot="day-strip"]')).toBeVisible();
|
||||
await page.evaluate(() => {
|
||||
const field = document.createElement("input");
|
||||
field.setAttribute("data-testid", "probe-field");
|
||||
field.setAttribute("aria-label", "Probe");
|
||||
document.getElementById("main-content")?.prepend(field);
|
||||
});
|
||||
await page.getByTestId("probe-field").focus();
|
||||
await page.keyboard.type("gp");
|
||||
await expect(page.getByTestId("probe-field")).toHaveValue("gp");
|
||||
await expect(panel).toHaveCount(0);
|
||||
|
||||
await page.locator("#main-content").focus();
|
||||
await page.keyboard.press("Shift+?");
|
||||
await expect(helpDialog(page)).toBeVisible();
|
||||
await page.keyboard.press("g");
|
||||
await page.keyboard.press("p");
|
||||
await expect(panel).toHaveCount(0);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(helpDialog(page)).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
+72
-9
@@ -306,6 +306,68 @@ test("value lines: quarterly means, gaps kept, short ones bridged, as many as th
|
||||
await expect(last).toBeInViewport();
|
||||
});
|
||||
|
||||
test("each mean's dot sits on the middle of its text and never wraps apart from it", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await setTimelineModuleOn(page);
|
||||
await mockTimeline(page, "full", TODAY);
|
||||
const check = async (slot: string) => {
|
||||
const rows = page.locator(`[data-slot="${slot}"]`);
|
||||
await expect(rows.first()).toBeVisible({ timeout: 20_000 });
|
||||
const units = await rows.evaluateAll((els) =>
|
||||
els.flatMap((row) =>
|
||||
Array.from(
|
||||
row.querySelectorAll('[data-slot="timeline-mean-part"]'),
|
||||
).map((unit) => {
|
||||
const dot = unit
|
||||
.querySelector('[data-slot="timeline-series-dot"]')!
|
||||
.getBoundingClientRect();
|
||||
const text = unit.querySelector(".truncate")!.getBoundingClientRect();
|
||||
return {
|
||||
dotMid: dot.top + dot.height / 2,
|
||||
textMid: text.top + text.height / 2,
|
||||
dotRight: dot.right,
|
||||
textLeft: text.left,
|
||||
textHeight: text.height,
|
||||
};
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(units.length).toBeGreaterThan(0);
|
||||
for (const unit of units) {
|
||||
// Centred on the text line, to the pixel.
|
||||
expect(Math.abs(unit.dotMid - unit.textMid)).toBeLessThanOrEqual(1);
|
||||
// On the same line, the value right after its dot: one line high.
|
||||
expect(unit.textLeft).toBeGreaterThan(unit.dotRight);
|
||||
expect(unit.textLeft - unit.dotRight).toBeLessThanOrEqual(8);
|
||||
expect(unit.textHeight).toBeLessThanOrEqual(20);
|
||||
}
|
||||
};
|
||||
|
||||
// A narrow chart: the selection bar's means wrap between units only.
|
||||
await page.setViewportSize({ width: 800, height: 900 });
|
||||
await page.goto("/timeline");
|
||||
await expect(page.locator('[data-slot="timeline-chart"]')).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
await check("timeline-selection-means");
|
||||
await testInfo.attach("selection-means-800", {
|
||||
body: await page
|
||||
.locator('[data-slot="timeline-selection-bar"]')
|
||||
.screenshot(),
|
||||
contentType: "image/png",
|
||||
});
|
||||
|
||||
// The phone chronicle's means follow the same rule.
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto("/timeline");
|
||||
await check("timeline-chronicle-means");
|
||||
await testInfo.attach("chronicle-means-390", {
|
||||
body: await page.screenshot(),
|
||||
contentType: "image/png",
|
||||
});
|
||||
});
|
||||
|
||||
test("one medication is one row: its doses cut the bar, its pause is a gap in it", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -621,8 +683,10 @@ test("an open day stays open from the timeline to another page", async ({
|
||||
day,
|
||||
);
|
||||
|
||||
// Folded by the person, it stays folded on the next page.
|
||||
await page.locator('[data-slot="day-close"]').click();
|
||||
// Folded by the person (the strip is the docked day's only fold control),
|
||||
// it stays folded on the next page.
|
||||
await expect(page.locator('[data-slot="day-close"]')).toHaveCount(0);
|
||||
await page.locator('[data-slot="day-strip-toggle"]').click();
|
||||
await expect(panel).toHaveCount(0);
|
||||
await page.locator('a[href="/timeline"]').first().click();
|
||||
await expect(page).toHaveURL(/\/timeline$/);
|
||||
@@ -799,9 +863,6 @@ test("the day's strip stays at the right edge, opens and closes the day, and nev
|
||||
docSw: doc.scrollWidth - doc.clientWidth,
|
||||
docSh: doc.scrollHeight - doc.clientHeight,
|
||||
mainSw: main.scrollWidth - main.clientWidth,
|
||||
bar: document
|
||||
.querySelector('[data-slot="top-bar"]')!
|
||||
.getBoundingClientRect().bottom,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -812,17 +873,19 @@ test("the day's strip stays at the right edge, opens and closes the day, and nev
|
||||
page.locator('[data-slot="timeline-chart"] > svg'),
|
||||
).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
// Closed: the strip is there, at the right edge, below the top bar's
|
||||
// band, holding today.
|
||||
// Closed: the strip is there, at the right edge, the full height of the
|
||||
// window from its top edge, holding today.
|
||||
await expect(strip).toHaveAttribute("data-state", "closed");
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", "false");
|
||||
const closed = await where();
|
||||
expect(closed[0] + closed[2], `${width}px right edge`).toBe(width);
|
||||
const before = await measure();
|
||||
expect(closed[1], `${width}px from the top edge`).toBe(0);
|
||||
expect(closed[3], `${width}px full height`).toBe(900);
|
||||
expect(
|
||||
Math.round((await toggle.boundingBox())!.y),
|
||||
`${width}px below the band`,
|
||||
).toBe(Math.round(before.bar));
|
||||
`${width}px toggle from the top edge`,
|
||||
).toBe(0);
|
||||
expect(before.docSw).toBeLessThanOrEqual(0);
|
||||
expect(before.mainSw).toBeLessThanOrEqual(0);
|
||||
|
||||
|
||||
+2
-3
@@ -12559,8 +12559,6 @@
|
||||
}
|
||||
},
|
||||
"palette": {
|
||||
"open": "Suchen…",
|
||||
"openLabel": "Seiten, Einstellungen und Aktionen suchen",
|
||||
"title": "Suche",
|
||||
"description": "Finde eine Seite, eine Einstellung, ein Modul oder eine Aktion.",
|
||||
"placeholder": "Seiten, Einstellungen, Module suchen…",
|
||||
@@ -12597,7 +12595,8 @@
|
||||
"close": "Dialog oder Bereich schließen",
|
||||
"previousDay": "Vorheriger Tag",
|
||||
"nextDay": "Nächster Tag",
|
||||
"dayHint": "Während ein Tag geöffnet ist.",
|
||||
"toggleDay": "Tag öffnen oder schließen",
|
||||
"dayHint": "Vorheriger und nächster Tag gehen, solange ein Tag offen ist.",
|
||||
"groups": {
|
||||
"navigation": "Navigation",
|
||||
"actions": "Aktionen",
|
||||
|
||||
+2
-3
@@ -12559,8 +12559,6 @@
|
||||
}
|
||||
},
|
||||
"palette": {
|
||||
"open": "Search…",
|
||||
"openLabel": "Search pages, settings and actions",
|
||||
"title": "Search",
|
||||
"description": "Find a page, a setting, a module or an action.",
|
||||
"placeholder": "Search pages, settings, modules…",
|
||||
@@ -12597,7 +12595,8 @@
|
||||
"close": "Close a dialog or panel",
|
||||
"previousDay": "Previous day",
|
||||
"nextDay": "Next day",
|
||||
"dayHint": "While a day is open.",
|
||||
"toggleDay": "Open or close the day",
|
||||
"dayHint": "Previous and next day work while a day is open.",
|
||||
"groups": {
|
||||
"navigation": "Navigation",
|
||||
"actions": "Actions",
|
||||
|
||||
+2
-3
@@ -12559,8 +12559,6 @@
|
||||
}
|
||||
},
|
||||
"palette": {
|
||||
"open": "Buscar…",
|
||||
"openLabel": "Buscar páginas, ajustes y acciones",
|
||||
"title": "Buscar",
|
||||
"description": "Encuentra una página, un ajuste, un módulo o una acción.",
|
||||
"placeholder": "Buscar páginas, ajustes, módulos…",
|
||||
@@ -12597,7 +12595,8 @@
|
||||
"close": "Cerrar un diálogo o panel",
|
||||
"previousDay": "Día anterior",
|
||||
"nextDay": "Día siguiente",
|
||||
"dayHint": "Mientras hay un día abierto.",
|
||||
"toggleDay": "Abrir o cerrar el día",
|
||||
"dayHint": "Día anterior y siguiente funcionan mientras hay un día abierto.",
|
||||
"groups": {
|
||||
"navigation": "Navegación",
|
||||
"actions": "Acciones",
|
||||
|
||||
+2
-3
@@ -12559,8 +12559,6 @@
|
||||
}
|
||||
},
|
||||
"palette": {
|
||||
"open": "Rechercher…",
|
||||
"openLabel": "Rechercher des pages, réglages et actions",
|
||||
"title": "Recherche",
|
||||
"description": "Trouver une page, un réglage, un module ou une action.",
|
||||
"placeholder": "Rechercher des pages, réglages, modules…",
|
||||
@@ -12597,7 +12595,8 @@
|
||||
"close": "Fermer une boîte de dialogue ou un panneau",
|
||||
"previousDay": "Jour précédent",
|
||||
"nextDay": "Jour suivant",
|
||||
"dayHint": "Lorsqu’un jour est ouvert.",
|
||||
"toggleDay": "Ouvrir ou fermer le jour",
|
||||
"dayHint": "Jour précédent et suivant fonctionnent quand un jour est ouvert.",
|
||||
"groups": {
|
||||
"navigation": "Navigation",
|
||||
"actions": "Actions",
|
||||
|
||||
+2
-3
@@ -12559,8 +12559,6 @@
|
||||
}
|
||||
},
|
||||
"palette": {
|
||||
"open": "Cerca…",
|
||||
"openLabel": "Cerca pagine, impostazioni e azioni",
|
||||
"title": "Cerca",
|
||||
"description": "Trova una pagina, un’impostazione, un modulo o un’azione.",
|
||||
"placeholder": "Cerca pagine, impostazioni, moduli…",
|
||||
@@ -12597,7 +12595,8 @@
|
||||
"close": "Chiudi una finestra o un pannello",
|
||||
"previousDay": "Giorno precedente",
|
||||
"nextDay": "Giorno successivo",
|
||||
"dayHint": "Quando un giorno è aperto.",
|
||||
"toggleDay": "Apri o chiudi il giorno",
|
||||
"dayHint": "Giorno precedente e successivo funzionano mentre un giorno è aperto.",
|
||||
"groups": {
|
||||
"navigation": "Navigazione",
|
||||
"actions": "Azioni",
|
||||
|
||||
+2
-3
@@ -12559,8 +12559,6 @@
|
||||
}
|
||||
},
|
||||
"palette": {
|
||||
"open": "검색…",
|
||||
"openLabel": "페이지, 설정, 작업 검색",
|
||||
"title": "검색",
|
||||
"description": "페이지, 설정, 모듈 또는 작업을 찾습니다.",
|
||||
"placeholder": "페이지, 설정, 모듈 검색…",
|
||||
@@ -12597,7 +12595,8 @@
|
||||
"close": "대화 상자나 패널 닫기",
|
||||
"previousDay": "이전 날",
|
||||
"nextDay": "다음 날",
|
||||
"dayHint": "하루가 열려 있을 때.",
|
||||
"toggleDay": "하루 열기 또는 닫기",
|
||||
"dayHint": "이전 날과 다음 날은 하루가 열려 있을 때 작동합니다.",
|
||||
"groups": {
|
||||
"navigation": "탐색",
|
||||
"actions": "작업",
|
||||
|
||||
+2
-3
@@ -12559,8 +12559,6 @@
|
||||
}
|
||||
},
|
||||
"palette": {
|
||||
"open": "Szukaj…",
|
||||
"openLabel": "Szukaj stron, ustawień i akcji",
|
||||
"title": "Szukaj",
|
||||
"description": "Znajdź stronę, ustawienie, moduł lub akcję.",
|
||||
"placeholder": "Szukaj stron, ustawień, modułów…",
|
||||
@@ -12597,7 +12595,8 @@
|
||||
"close": "Zamknij okno lub panel",
|
||||
"previousDay": "Poprzedni dzień",
|
||||
"nextDay": "Następny dzień",
|
||||
"dayHint": "Gdy otwarty jest dzień.",
|
||||
"toggleDay": "Otwórz lub zamknij dzień",
|
||||
"dayHint": "Poprzedni i następny dzień działają, gdy dzień jest otwarty.",
|
||||
"groups": {
|
||||
"navigation": "Nawigacja",
|
||||
"actions": "Akcje",
|
||||
|
||||
@@ -17,47 +17,23 @@ import { openCommandPalette, preloadCommandPalette } from "./palette-store";
|
||||
|
||||
/**
|
||||
* The way into the command palette, at the trailing end of the top bar on
|
||||
* every signed-in page. Where the bar has room it looks like a search field
|
||||
* ("Search…" and the key that opens it); where it has not (a phone, or a
|
||||
* desktop bar narrowed by the sidebar and a docked panel) it is a magnifier
|
||||
* with a tooltip. The switch reads the bar's own width (`@container/topbar`
|
||||
* on the header), not the window's, because a docked panel takes the room
|
||||
* without the window changing.
|
||||
* every signed-in page: a magnifier at every width, named "Search", with a
|
||||
* tooltip that gives the key that opens it as well ("Search (⌘K)", or
|
||||
* "Search (Ctrl K)" off Apple platforms). 44 px under a coarse pointer, 36 px
|
||||
* beside a fine one. Docked panels' strips stand right of the top bar, so the
|
||||
* magnifier always sits just left of them and nothing covers it.
|
||||
*/
|
||||
export function CommandPaletteTrigger() {
|
||||
const { t } = useTranslations();
|
||||
// The key hint depends on the platform, which the server cannot know.
|
||||
const mounted = useMounted();
|
||||
const apple = mounted && isApplePlatform();
|
||||
const label = t("palette.openLabel");
|
||||
const label = t("palette.title");
|
||||
const keyHint = apple ? "⌘K" : "Ctrl K";
|
||||
const shortcut = apple ? "Meta+K" : "Control+K";
|
||||
|
||||
return (
|
||||
<div className="ml-auto flex shrink-0 items-center">
|
||||
<button
|
||||
type="button"
|
||||
data-slot="command-palette-trigger"
|
||||
data-variant="field"
|
||||
aria-label={label}
|
||||
aria-keyshortcuts={mounted ? shortcut : undefined}
|
||||
aria-haspopup="dialog"
|
||||
onClick={openCommandPalette}
|
||||
onPointerEnter={preloadCommandPalette}
|
||||
onFocus={preloadCommandPalette}
|
||||
className="border-border bg-background text-muted-foreground hover:text-foreground hover:bg-accent focus-visible:ring-ring/50 hidden h-9 w-56 items-center gap-2 rounded-md border px-3 text-sm transition-colors outline-none focus-visible:ring-[3px] @2xl/topbar:flex"
|
||||
>
|
||||
<Search className="size-4 shrink-0" aria-hidden="true" />
|
||||
<span className="flex-1 truncate text-left">{t("palette.open")}</span>
|
||||
{mounted ? (
|
||||
<kbd
|
||||
aria-hidden="true"
|
||||
className="border-border bg-muted text-foreground rounded border px-1.5 font-mono text-xs"
|
||||
>
|
||||
{keyHint}
|
||||
</kbd>
|
||||
) : null}
|
||||
</button>
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -66,19 +42,20 @@ export function CommandPaletteTrigger() {
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-slot="command-palette-trigger"
|
||||
data-variant="icon"
|
||||
aria-label={label}
|
||||
aria-keyshortcuts={mounted ? shortcut : undefined}
|
||||
aria-haspopup="dialog"
|
||||
onClick={openCommandPalette}
|
||||
onPointerEnter={preloadCommandPalette}
|
||||
onFocus={preloadCommandPalette}
|
||||
className="text-muted-foreground hover:text-foreground min-h-11 min-w-11 @2xl/topbar:hidden"
|
||||
className="text-muted-foreground hover:text-foreground size-11 pointer-fine:size-9"
|
||||
>
|
||||
<Search className="size-5" aria-hidden="true" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{label}</TooltipContent>
|
||||
<TooltipContent side="bottom" data-slot="command-palette-tooltip">
|
||||
{mounted ? `${label} (${keyHint})` : label}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
|
||||
@@ -243,16 +243,19 @@ describe("<DayView>", () => {
|
||||
expect(leave).not.toContain("FAMILY");
|
||||
});
|
||||
|
||||
it("collapses the docked day and closes a sheet, and says which", async () => {
|
||||
it("has no close button docked, where the strip folds it, and a clear one in a sheet", async () => {
|
||||
const docked = await render();
|
||||
const hide = docked.match(/<button[^>]*data-slot="day-close"[^>]*>/)?.[0];
|
||||
expect(hide).toContain('aria-label="Hide day"');
|
||||
expect(hide).toContain('aria-expanded="true"');
|
||||
expect(docked).not.toContain('data-slot="day-close"');
|
||||
for (const shell of ["sheet", "bottom"] as const) {
|
||||
const sheet = await render({ shell });
|
||||
const close = sheet.match(/<button[^>]*data-slot="day-close"[^>]*>/)?.[0];
|
||||
expect(close).toContain('aria-label="Close day"');
|
||||
expect(close).not.toContain("aria-expanded");
|
||||
// An X, at the end of the header row, after the next-day arrow.
|
||||
expect(sheet.indexOf('data-slot="day-close"')).toBeGreaterThan(
|
||||
sheet.indexOf('data-slot="day-next"'),
|
||||
);
|
||||
expect(sheet).toContain("lucide-x");
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -185,6 +185,27 @@ export function jumpDay(next: DateKey): void {
|
||||
* Coach's conversations below 1600 px): focus then stays with the control
|
||||
* that asked for the room instead of moving to the day's edge.
|
||||
*/
|
||||
let dayToggle: (() => void) | null = null;
|
||||
|
||||
/**
|
||||
* The mounted layer's open-or-close, the same as a click on the day's strip
|
||||
* (a sheet with the strip's day below the docking width). The layer
|
||||
* registers it; `g p` calls it through `toggleDay`.
|
||||
*/
|
||||
export function registerDayToggle(toggle: () => void): () => void {
|
||||
dayToggle = toggle;
|
||||
return () => {
|
||||
if (dayToggle === toggle) dayToggle = null;
|
||||
};
|
||||
}
|
||||
|
||||
/** Open or close the day. False when no day layer is mounted. */
|
||||
export function toggleDay(): boolean {
|
||||
if (dayToggle === null) return false;
|
||||
dayToggle();
|
||||
return true;
|
||||
}
|
||||
|
||||
let yielding = false;
|
||||
|
||||
/** Collapse the docked day to its edge for a neighbouring panel. */
|
||||
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
openDay,
|
||||
peekDayTriggerAt,
|
||||
publishOpenDay,
|
||||
registerDayToggle,
|
||||
stepDay,
|
||||
takeDayTrigger,
|
||||
takeDayYield,
|
||||
@@ -255,8 +256,8 @@ function DayLayer() {
|
||||
}, []);
|
||||
const panelRef = useRef<HTMLElement>(null);
|
||||
const stripRef = useRef<HTMLButtonElement>(null);
|
||||
// Set by a fold (the header's control, the strip): focus then lands on the
|
||||
// strip, which brings the day back from the same place.
|
||||
// Set by a fold (the strip, its shortcut): focus then lands on the strip,
|
||||
// which brings the day back from the same place.
|
||||
const collapsing = useRef(false);
|
||||
|
||||
// The remembered day, while the docked day is shut, if it is still a day
|
||||
@@ -353,6 +354,33 @@ function DayLayer() {
|
||||
[date, today],
|
||||
);
|
||||
|
||||
// What the strip holds: the open day, else the remembered one, else today.
|
||||
// Below the docking width the same day is the one `g p` opens.
|
||||
const stripDay = stripDayOf(date, lastDay, today);
|
||||
const dockedOpen = dockedDay !== null;
|
||||
// The strip's click, and `g p` at every width: docked, the column opens
|
||||
// or folds; below, the sheet opens on the strip's day or closes.
|
||||
const toggle = useCallback(() => {
|
||||
if (shell === "docked") {
|
||||
if (dockedOpen) {
|
||||
collapse();
|
||||
return;
|
||||
}
|
||||
openDay(stripDay, { trigger: stripRef.current ?? undefined });
|
||||
return;
|
||||
}
|
||||
if (date !== null) {
|
||||
closeByPerson();
|
||||
return;
|
||||
}
|
||||
openDay(stripDay);
|
||||
}, [shell, dockedOpen, collapse, stripDay, date, closeByPerson]);
|
||||
const latestToggle = useRef(toggle);
|
||||
useEffect(() => {
|
||||
latestToggle.current = toggle;
|
||||
});
|
||||
useEffect(() => registerDayToggle(() => latestToggle.current()), []);
|
||||
|
||||
const onPick = useCallback(
|
||||
(next: DateKey) => {
|
||||
if (next > today) return;
|
||||
@@ -418,7 +446,8 @@ function DayLayer() {
|
||||
today={today}
|
||||
focus={focus}
|
||||
shell={shell}
|
||||
onClose={shell === "docked" ? collapse : closeByPerson}
|
||||
// Only the sheets show a close button; docked, the strip folds the day.
|
||||
onClose={closeByPerson}
|
||||
onStep={onStep}
|
||||
onPick={onPick}
|
||||
Title={Title}
|
||||
@@ -429,16 +458,7 @@ function DayLayer() {
|
||||
);
|
||||
|
||||
if (shell === "docked") {
|
||||
// What the strip holds: the open day, else the remembered one, else today.
|
||||
const stripDay = stripDayOf(date, railDay, today);
|
||||
const open = dockedDay !== null;
|
||||
const toggle = () => {
|
||||
if (open) {
|
||||
collapse();
|
||||
return;
|
||||
}
|
||||
openDay(stripDay, { trigger: stripRef.current ?? undefined });
|
||||
};
|
||||
const open = dockedOpen;
|
||||
return (
|
||||
<>
|
||||
{live}
|
||||
|
||||
@@ -8,9 +8,8 @@ import {
|
||||
ChevronRight,
|
||||
History,
|
||||
MessageCircle,
|
||||
PanelBottomClose,
|
||||
PanelRightClose,
|
||||
Plus,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
import {
|
||||
@@ -84,15 +83,12 @@ const ICON_BUTTON =
|
||||
"text-muted-foreground hover:text-foreground size-11 shrink-0 pointer-fine:size-9";
|
||||
|
||||
/**
|
||||
* The collapse control is the Coach panel's own toggle, in the same place:
|
||||
* the panel's top-left corner, the panel icon mirrored so it points the way
|
||||
* the panel goes. Same size and glyph as `PANEL_HEADER_BUTTON` there (kept as
|
||||
* a copy so the day layer does not pull the Coach's panel into the shell).
|
||||
* The collapsed edge's control in `day-layer.tsx` takes the same pair.
|
||||
* The sheets' close control, at the trailing end of the header like the
|
||||
* Coach sheet's own. The docked day has none: its strip at the right edge of
|
||||
* the window (`shell-dock.tsx`) is the one control that opens and closes it.
|
||||
*/
|
||||
export const PANEL_TOGGLE =
|
||||
const CLOSE_BUTTON =
|
||||
"text-muted-foreground hover:text-foreground size-11 shrink-0 pointer-fine:size-7";
|
||||
export const PANEL_TOGGLE_ICON = "size-5 pointer-fine:size-4";
|
||||
|
||||
export function DayView({
|
||||
date,
|
||||
@@ -157,7 +153,7 @@ export function DayView({
|
||||
? Object.values(data.sections).some((s) => s?.reason === "not_shared")
|
||||
: false;
|
||||
const compact = shell === "bottom";
|
||||
const closeLabel = shell === "docked" ? t("day.hidePanel") : t("day.close");
|
||||
const docked = shell === "docked";
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -170,41 +166,19 @@ export function DayView({
|
||||
data-slot="day-header"
|
||||
className={cn(
|
||||
"flex shrink-0 items-center gap-1",
|
||||
compact ? "pt-0.5 pr-2 pb-2 pl-2" : "border-border border-b px-3",
|
||||
// The date starts on the body's reading edge (`px-4` / `px-6`).
|
||||
compact
|
||||
? "pt-0.5 pr-2 pb-2 pl-4"
|
||||
: "border-border border-b pr-3 pl-6",
|
||||
headerClassName,
|
||||
)}
|
||||
>
|
||||
{/* Top left, where the Coach panel keeps its toggle: the way out.
|
||||
Docked, it collapses the day to the edge; a sheet closes. */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-slot="day-close"
|
||||
aria-label={closeLabel}
|
||||
aria-expanded={shell === "docked" ? true : undefined}
|
||||
title={closeLabel}
|
||||
onClick={onClose}
|
||||
className={PANEL_TOGGLE}
|
||||
>
|
||||
{compact ? (
|
||||
<PanelBottomClose
|
||||
className={PANEL_TOGGLE_ICON}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : (
|
||||
<PanelRightClose
|
||||
className={cn(PANEL_TOGGLE_ICON, "-scale-x-100")}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
{/* One line, centred in the band: the date and nothing under it. */}
|
||||
<Title
|
||||
id={titleId}
|
||||
ref={titleRef}
|
||||
tabIndex={-1}
|
||||
className="min-w-0 flex-1 truncate px-1 text-base leading-snug font-semibold focus-visible:outline-none"
|
||||
className="min-w-0 flex-1 truncate pr-1 text-base leading-snug font-semibold focus-visible:outline-none"
|
||||
>
|
||||
{/* The date is the way to any other day: a calendar opens from
|
||||
it, so the header needs no icon of its own. */}
|
||||
@@ -262,6 +236,20 @@ export function DayView({
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Button>
|
||||
{docked ? null : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-slot="day-close"
|
||||
aria-label={t("day.close")}
|
||||
title={t("day.close")}
|
||||
onClick={onClose}
|
||||
className={CLOSE_BUTTON}
|
||||
>
|
||||
<X className="size-5 pointer-fine:size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
|
||||
@@ -58,8 +58,9 @@ import { useDeleteCoachConversationWithUndo } from "./use-coach";
|
||||
* the two bottom borders draw one line. Its strip stays at the right edge of
|
||||
* the window, left of the day's (`shell-dock.tsx`), open or not: a click
|
||||
* on the strip opens the panel left of the strips and a second click shuts
|
||||
* it; the header's first control shuts it too. The panel slides open and
|
||||
* shut (a 200 ms width transition, none under reduced motion).
|
||||
* it. The strip is the docked panel's only fold control; the header carries
|
||||
* none. The panel slides open and shut (a 200 ms width transition, none
|
||||
* under reduced motion).
|
||||
* The header carries the title, a link to Plans, the settings gear and, in
|
||||
* the sheet, a close button. New conversation is not here: it floats in the
|
||||
* conversation itself (`NewChatFab`).
|
||||
@@ -75,7 +76,7 @@ import { useDeleteCoachConversationWithUndo } from "./use-coach";
|
||||
* Keyboard: Escape folds the docked panel when focus is inside it and
|
||||
* nothing inside (a row menu, the rename field, the settings popover) has
|
||||
* already claimed the key; focus moves to the strip, and opening from the
|
||||
* strip lands on the fold control. In the sheet Radix owns Escape, the
|
||||
* strip lands on the panel's heading. In the sheet Radix owns Escape, the
|
||||
* focus trap and the return.
|
||||
*/
|
||||
export const COACH_PANEL_ID = "coach-conversations-panel";
|
||||
@@ -286,17 +287,17 @@ export function ConversationsPanel({
|
||||
}
|
||||
|
||||
const expanded = docked ? dockedOpen : sheetOpen;
|
||||
const collapseRef = useRef<HTMLButtonElement>(null);
|
||||
const headingRef = useRef<HTMLHeadingElement>(null);
|
||||
const expandRef = useRef<HTMLButtonElement>(null);
|
||||
// The list keeps painting while it slides shut.
|
||||
const shownOpen = useLingering(docked && dockedOpen ? true : null) ?? false;
|
||||
// Where focus goes once the docked panel has folded or opened.
|
||||
const focusAfter = useRef<"collapse" | "expand" | null>(null);
|
||||
const focusAfter = useRef<"panel" | "strip" | null>(null);
|
||||
useEffect(() => {
|
||||
const target = focusAfter.current;
|
||||
focusAfter.current = null;
|
||||
if (target === "collapse") collapseRef.current?.focus();
|
||||
if (target === "expand") expandRef.current?.focus();
|
||||
if (target === "panel") headingRef.current?.focus({ preventScroll: true });
|
||||
if (target === "strip") expandRef.current?.focus();
|
||||
}, [dockedOpen]);
|
||||
|
||||
function toggleSheet() {
|
||||
@@ -304,7 +305,7 @@ export function ConversationsPanel({
|
||||
}
|
||||
|
||||
function expandDocked() {
|
||||
focusAfter.current = "collapse";
|
||||
focusAfter.current = "panel";
|
||||
setYielded(false);
|
||||
setRevealed(false);
|
||||
setOpen(true);
|
||||
@@ -313,7 +314,7 @@ export function ConversationsPanel({
|
||||
}
|
||||
|
||||
function collapseDocked() {
|
||||
focusAfter.current = "expand";
|
||||
focusAfter.current = "strip";
|
||||
setYielded(false);
|
||||
setRevealed(false);
|
||||
setOpen(false);
|
||||
@@ -344,37 +345,19 @@ export function ConversationsPanel({
|
||||
: cn("border-sidebar-border px-3", SHELL_HEADER_BAND),
|
||||
)}
|
||||
>
|
||||
{inSheet ? null : (
|
||||
// A title, not a tooltip: focus lands here when the panel opens
|
||||
// from its edge, and a tooltip opened by that focus would take the
|
||||
// next Escape for itself.
|
||||
<Button
|
||||
ref={collapseRef}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={collapseDocked}
|
||||
data-slot="coach-panel-collapse"
|
||||
aria-controls={COACH_PANEL_ID}
|
||||
aria-expanded={true}
|
||||
aria-label={t("insights.coach.frame.hidePanel")}
|
||||
title={t("insights.coach.frame.hidePanel")}
|
||||
className={PANEL_HEADER_BUTTON}
|
||||
>
|
||||
{/* The day's own fold control and glyph, mirrored so it points
|
||||
the way the panel goes. */}
|
||||
<PanelRightClose
|
||||
className={cn(PANEL_HEADER_ICON, "-scale-x-100")}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
{inSheet ? (
|
||||
<SheetTitle className="min-w-0 flex-1 truncate text-lg leading-tight font-semibold">
|
||||
{t("insights.coach.historyTitle")}
|
||||
</SheetTitle>
|
||||
) : (
|
||||
<h2 className="min-w-0 flex-1 truncate text-lg leading-tight font-semibold">
|
||||
// Docked, the strip is the panel's only fold control; focus lands on
|
||||
// the heading when the panel opens from it.
|
||||
<h2
|
||||
ref={headingRef}
|
||||
tabIndex={-1}
|
||||
data-slot="coach-panel-heading"
|
||||
className="min-w-0 flex-1 truncate pl-1 text-lg leading-tight font-semibold focus-visible:outline-none"
|
||||
>
|
||||
{t("insights.coach.historyTitle")}
|
||||
</h2>
|
||||
)}
|
||||
|
||||
@@ -15,7 +15,7 @@ import { DOCK_SLIDE, DockStrip } from "../shell-dock";
|
||||
* The docked panels' strips (v1.42). Each docked panel keeps its own strip
|
||||
* at the right edge; the strip is a toggle for its panel, never moves, and
|
||||
* below 1600 px only one panel is open. The click paths, focus and the
|
||||
* geometry run in `e2e/dock-strips.spec.ts`.
|
||||
* geometry run in `e2e/day-view.spec.ts` and `e2e/coach-layout.spec.ts`.
|
||||
*/
|
||||
function strip(expanded: boolean) {
|
||||
return renderToStaticMarkup(
|
||||
@@ -56,14 +56,14 @@ describe("DockStrip", () => {
|
||||
expect(html).toContain('aria-label="Hide day"');
|
||||
});
|
||||
|
||||
it("keeps the same shape open and closed, below the top bar's band", () => {
|
||||
it("keeps the same shape open and closed, the full height of the window", () => {
|
||||
const shape = (html: string) => html.match(/^<div[^>]*class="([^"]*)"/)![1];
|
||||
expect(shape(strip(true))).toBe(shape(strip(false)));
|
||||
expect(shape(strip(false))).toContain("w-10");
|
||||
// The first cell is the band (`SHELL_HEADER_BAND`), empty and hidden.
|
||||
expect(strip(false)).toMatch(
|
||||
/^<div[^>]*><div aria-hidden="true" class="h-16 border-b/,
|
||||
);
|
||||
expect(shape(strip(false))).toContain("h-full");
|
||||
// The button is the strip's first and only cell: no band above it.
|
||||
expect(strip(false)).toMatch(/^<div[^>]*><button/);
|
||||
expect(strip(false)).not.toContain("h-16");
|
||||
});
|
||||
|
||||
it("orders the strips like their panels: conversations, then the day", () => {
|
||||
|
||||
@@ -9,7 +9,11 @@ import {
|
||||
useSyncExternalStore,
|
||||
} from "react";
|
||||
|
||||
import { stepDay, useOpenDay } from "@/components/day/day-layer-controller";
|
||||
import {
|
||||
stepDay,
|
||||
toggleDay,
|
||||
useOpenDay,
|
||||
} from "@/components/day/day-layer-controller";
|
||||
import { useTodayKey } from "@/components/day/use-today-key";
|
||||
import {
|
||||
CAPTURE_KIND_ORDER,
|
||||
@@ -38,8 +42,8 @@ import {
|
||||
* (never on the sign-in pages or in the setup flow, which the shell renders
|
||||
* without it).
|
||||
*
|
||||
* `g` + a key opens a page, `n` the add menu, `[` / `]` step the open day,
|
||||
* `?` the list of all of them. Which key does what, and when a key is not
|
||||
* `g` + a key opens a page, `g p` opens or closes the day, `n` the add menu,
|
||||
* `[` / `]` step the open day, `?` the list of all of them. Which key does what, and when a key is not
|
||||
* ours, is decided in `src/lib/keyboard/global-shortcuts.ts`; this component
|
||||
* reads the page's state and carries the action out. A destination comes from
|
||||
* the same lists the navigation renders, so `g t` with the timeline module
|
||||
@@ -196,6 +200,9 @@ export function GlobalShortcuts() {
|
||||
stepDay(date, action.delta);
|
||||
return;
|
||||
}
|
||||
case "day-toggle":
|
||||
if (toggleDay()) event.preventDefault();
|
||||
return;
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
|
||||
@@ -92,6 +92,7 @@ export function KeyboardShortcutsDialog({
|
||||
];
|
||||
|
||||
const day: Row[] = [
|
||||
{ id: "day-toggle", label: t("shortcuts.toggleDay"), sequence: ["g", "p"] },
|
||||
{ id: "day-previous", label: t("shortcuts.previousDay"), sequence: ["["] },
|
||||
{ id: "day-next", label: t("shortcuts.nextDay"), sequence: ["]"] },
|
||||
];
|
||||
|
||||
@@ -5,7 +5,6 @@ import type { LucideIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { SHELL_HEADER_BAND } from "./shell-metrics";
|
||||
import { createShellSlot } from "./shell-slot";
|
||||
|
||||
/**
|
||||
@@ -17,9 +16,12 @@ import { createShellSlot } from "./shell-slot";
|
||||
* panels: conversations (`order-1`), then the day (`order-2`). A panel opens
|
||||
* to the left of the strips; the strips never move or change shape.
|
||||
*
|
||||
* Each strip starts below the top bar's band. The band above them is empty
|
||||
* and draws the top bar's bottom line on through, so nothing in the strips
|
||||
* meets the command palette at the end of the top bar.
|
||||
* Each strip runs the full height of the shell row, from the top of the
|
||||
* window to the bottom. The strip column sits beside the content column, so
|
||||
* the top bar (and the command palette's magnifier at its trailing end) ends
|
||||
* at the strips' left edge and nothing in a strip can cover it. Docked, the
|
||||
* strip is the panel's only open and close control: the panel's header
|
||||
* carries no fold button of its own.
|
||||
*/
|
||||
const strips = createShellSlot("shell-strips");
|
||||
|
||||
@@ -89,9 +91,9 @@ export interface DockStripProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* One strip: an empty cell of the top bar's band, then a button the rest of
|
||||
* the window's height that opens and closes its panel. The label reads
|
||||
* top to bottom; the strip is marked while its panel is open.
|
||||
* One strip: a button the full height of the window that opens and closes
|
||||
* its panel. The label reads top to bottom; the strip is marked while its
|
||||
* panel is open.
|
||||
*/
|
||||
export function DockStrip({
|
||||
ref,
|
||||
@@ -116,10 +118,6 @@ export function DockStrip({
|
||||
order === 1 ? "order-1" : "order-2",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={cn(SHELL_HEADER_BAND, "border-sidebar-border shrink-0")}
|
||||
/>
|
||||
<button
|
||||
ref={ref}
|
||||
type="button"
|
||||
|
||||
@@ -90,9 +90,7 @@ export function TopBar() {
|
||||
// sidebar logo band reads too, so the two borders draw one
|
||||
// continuous line. Never restate a height here.
|
||||
className={cn(
|
||||
// `@container/topbar`: the search control sizes to the bar's own
|
||||
// width, which a docked side panel narrows without the window moving.
|
||||
"bg-card/80 border-border @container/topbar sticky top-0 z-40 flex items-center justify-between px-4 backdrop-blur-md md:px-6",
|
||||
"bg-card/80 border-border sticky top-0 z-40 flex items-center justify-between px-4 backdrop-blur-md md:px-6",
|
||||
SHELL_HEADER_BAND,
|
||||
)}
|
||||
// The status-bar inset of the installed app is the shell's
|
||||
@@ -114,8 +112,8 @@ export function TopBar() {
|
||||
<TopBarContextOutlet className="shell-desktop:flex hidden min-w-0 flex-1 items-center" />
|
||||
|
||||
{/* v1.42 — the command palette's door, on every signed-in page: a
|
||||
search-field button where the bar has room, a magnifier where it
|
||||
has not (and on a phone). `ml-auto` keeps it at the trailing end. */}
|
||||
magnifier at every width. `ml-auto` keeps it at the trailing end,
|
||||
just left of any docked panel's strip. */}
|
||||
<CommandPaletteTrigger />
|
||||
|
||||
{/* Page-owned actions (the Coach's conversations toggle). `ml-auto`
|
||||
|
||||
@@ -551,6 +551,43 @@ describe("value line colours", () => {
|
||||
expect(text(html)).toBe("129/82 mmHg, 82,6 kg");
|
||||
expect(html).toContain('class="sr-only">, </span>');
|
||||
});
|
||||
|
||||
it("keeps each dot and its value in one unit that never breaks", () => {
|
||||
const html = render(
|
||||
<MeanPartsLine
|
||||
lead="Ø"
|
||||
parts={[
|
||||
{ key: "BLOOD_PRESSURE_SYS", text: "129/82 mmHg" },
|
||||
{ key: "WEIGHT", text: "82,6 kg" },
|
||||
{ key: "STRAIN", text: "Daily strain with a very long name 12" },
|
||||
]}
|
||||
seriesColor={seriesColor}
|
||||
/>,
|
||||
);
|
||||
// Each unit opens with its own tag; everything up to the next unit's tag
|
||||
// (the lead, the dot, the value) is inside it.
|
||||
const units = html.split(/(?=<span[^>]*data-slot="timeline-mean-part")/);
|
||||
expect(units).toHaveLength(3);
|
||||
for (const unit of units) {
|
||||
const open = unit.slice(0, unit.indexOf(">"));
|
||||
expect(open).toContain("inline-flex");
|
||||
expect(open).toContain("items-center");
|
||||
expect(open).toContain("whitespace-nowrap");
|
||||
const dot = unit.match(
|
||||
/<span[^>]*data-slot="timeline-series-dot"[^>]*>/,
|
||||
)?.[0];
|
||||
expect(dot).toContain("shrink-0");
|
||||
expect(dot).not.toContain("align-middle");
|
||||
// The value follows the dot inside the same unit.
|
||||
expect(text(unit.slice(unit.indexOf(dot!)))).toMatch(/\S/);
|
||||
}
|
||||
// The lead rides in the first unit, so "Ø" never ends a line alone.
|
||||
expect(text(units[0]).startsWith("Ø")).toBe(true);
|
||||
// A long name is cut short inside its unit.
|
||||
expect(units[2]).toContain(
|
||||
'<span class="min-w-0 truncate">Daily strain with a very long name 12</span>',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("life events in the capture picker", () => {
|
||||
|
||||
@@ -39,7 +39,7 @@ import {
|
||||
} from "./timeline-dates";
|
||||
import { useItemWords } from "./item-words";
|
||||
import { itemsInPeriod } from "./timeline-geometry";
|
||||
import { MeanPartsLine, laneDotStyle } from "./timeline-chart";
|
||||
import { MEAN_PARTS_ROW, MeanPartsLine, laneDotStyle } from "./timeline-chart";
|
||||
import {
|
||||
bucketValues,
|
||||
labelledMeanParts,
|
||||
@@ -201,12 +201,12 @@ export function SelectionBar({
|
||||
})}
|
||||
{means.values.length > 0 && (
|
||||
<span
|
||||
className="text-muted-foreground text-xs tabular-nums"
|
||||
className={MEAN_PARTS_ROW}
|
||||
data-slot="timeline-selection-means"
|
||||
data-bucket={means.start}
|
||||
>
|
||||
{meanBefore}
|
||||
<MeanPartsLine
|
||||
lead={meanBefore || undefined}
|
||||
parts={labelledMeanParts(means.values, seriesFormat, {
|
||||
label: seriesLabel,
|
||||
bloodPressure: t("timeline.values.bloodPressure"),
|
||||
@@ -215,7 +215,7 @@ export function SelectionBar({
|
||||
})}
|
||||
seriesColor={seriesColor}
|
||||
/>
|
||||
{meanAfter}
|
||||
{meanAfter ? <span>{meanAfter}</span> : null}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1117,13 +1117,22 @@ function SeriesTable({
|
||||
* ● 82,6 kg". The values stand apart by space, not by a separator glyph;
|
||||
* a screen reader hears a comma between them. The dot is decoration; the
|
||||
* text reads the same without it.
|
||||
*
|
||||
* Each dot and its value are one unit that never breaks: a line wraps only
|
||||
* between units (the caller's container is a wrapping flex row), so a dot
|
||||
* cannot stay behind at the end of a line while its value moves to the
|
||||
* next. The dot sits on the middle of the text line, and a name too long for
|
||||
* the row is cut short inside its unit. `lead` (the "Ø" in front of the
|
||||
* selection bar's means) rides in the first unit for the same reason.
|
||||
*/
|
||||
export function MeanPartsLine({
|
||||
parts,
|
||||
seriesColor,
|
||||
lead,
|
||||
}: {
|
||||
parts: readonly MeanPart[];
|
||||
seriesColor: (key: string) => string;
|
||||
lead?: React.ReactNode;
|
||||
}) {
|
||||
return parts.map((part, i) => (
|
||||
<span
|
||||
@@ -1133,16 +1142,17 @@ export function MeanPartsLine({
|
||||
// The readings behind a mean: a hover title, and the same words for a
|
||||
// screen reader, but not on the line itself.
|
||||
title={part.detail}
|
||||
className={i > 0 ? "ml-3" : undefined}
|
||||
className="inline-flex max-w-full min-w-0 items-center gap-1 whitespace-nowrap"
|
||||
>
|
||||
{i === 0 && lead ? <span className="shrink-0">{lead}</span> : null}
|
||||
{i > 0 ? <span className="sr-only">, </span> : null}
|
||||
<span
|
||||
data-slot="timeline-series-dot"
|
||||
className="mr-1 inline-block size-2 rounded-full align-middle"
|
||||
className="size-2 shrink-0 rounded-full"
|
||||
style={{ background: seriesColor(part.key) }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{part.text}
|
||||
<span className="min-w-0 truncate">{part.text}</span>
|
||||
{part.detail ? (
|
||||
<span className="sr-only" data-slot="timeline-mean-detail">
|
||||
{`, ${part.detail}`}
|
||||
@@ -1152,6 +1162,10 @@ export function MeanPartsLine({
|
||||
));
|
||||
}
|
||||
|
||||
/** The row the units of a `MeanPartsLine` wrap in: breaks only between them. */
|
||||
export const MEAN_PARTS_ROW =
|
||||
"text-muted-foreground inline-flex max-w-full flex-wrap items-center gap-x-3 gap-y-1 text-xs tabular-nums";
|
||||
|
||||
/** Inline style for a lane colour dot in HTML (chips, chronicle rails). */
|
||||
export function laneDotStyle(lane: TimelineLaneKey): React.CSSProperties {
|
||||
return { background: LANE_COLOR[lane] };
|
||||
|
||||
@@ -50,7 +50,12 @@ import {
|
||||
formatMonthYear,
|
||||
} from "./timeline-dates";
|
||||
import { LANE_COLOR } from "./timeline-geometry";
|
||||
import { MeanPartsLine, bucketText, laneDotStyle } from "./timeline-chart";
|
||||
import {
|
||||
MEAN_PARTS_ROW,
|
||||
MeanPartsLine,
|
||||
bucketText,
|
||||
laneDotStyle,
|
||||
} from "./timeline-chart";
|
||||
import {
|
||||
chronicleMeans,
|
||||
meanParts,
|
||||
@@ -247,7 +252,7 @@ export function TimelineChronicle({
|
||||
className="bg-muted text-foreground text-2xs inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 font-medium"
|
||||
>
|
||||
<span
|
||||
className="size-1.5 rounded-full"
|
||||
className="size-1.5 shrink-0 rounded-full"
|
||||
style={laneDotStyle(chip.lane)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
@@ -295,7 +300,7 @@ export function TimelineChronicle({
|
||||
</h3>
|
||||
{bucketMeans && parts.length > 0 && (
|
||||
<span
|
||||
className="text-muted-foreground text-xs tabular-nums"
|
||||
className={MEAN_PARTS_ROW}
|
||||
data-slot="timeline-chronicle-means"
|
||||
data-bucket={bucketMeans.start}
|
||||
>
|
||||
|
||||
@@ -603,14 +603,14 @@ export function TimelineLegend({
|
||||
>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span
|
||||
className="bg-muted-foreground h-1.5 w-4 rounded-full"
|
||||
className="bg-muted-foreground h-1.5 w-4 shrink-0 rounded-full"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{t("timeline.legendSpan")}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span
|
||||
className="bg-muted-foreground size-2 rounded-full"
|
||||
className="bg-muted-foreground size-2 shrink-0 rounded-full"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{t("timeline.legendEvent")}
|
||||
@@ -622,7 +622,10 @@ export function TimelineLegend({
|
||||
data-slot="timeline-legend-mean"
|
||||
data-bucket={bucket}
|
||||
>
|
||||
<span className="bg-foreground h-0.5 w-4" aria-hidden="true" />
|
||||
<span
|
||||
className="bg-foreground h-0.5 w-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{t(TIMELINE_LEGEND_MEAN_KEY[bucket])}
|
||||
</span>
|
||||
<span
|
||||
|
||||
@@ -2,6 +2,8 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
import { visibleNavDestinations } from "@/components/layout/nav-model";
|
||||
import {
|
||||
DAY_TOGGLE_KEY,
|
||||
GO_TO_SHORTCUTS,
|
||||
SEQUENCE_TIMEOUT_MS,
|
||||
blockedByModifier,
|
||||
createShortcutReader,
|
||||
@@ -181,6 +183,40 @@ describe("sequence timing", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("g p, the day", () => {
|
||||
it("opens or closes the day from the page", () => {
|
||||
const reader = createShortcutReader();
|
||||
expect(reader.read(key("g"), "page", 0)).toBe("pending");
|
||||
expect(reader.read(key("p"), "page", 10)).toEqual({ type: "day-toggle" });
|
||||
});
|
||||
|
||||
it("collides with no destination", () => {
|
||||
expect(DAY_TOGGLE_KEY).toBe("p");
|
||||
const keys = GO_TO_SHORTCUTS.map((s) => s.key as string);
|
||||
expect(keys).not.toContain(DAY_TOGGLE_KEY);
|
||||
expect(new Set(keys).size).toBe(keys.length);
|
||||
});
|
||||
|
||||
it("needs the g, within the window", () => {
|
||||
const reader = createShortcutReader();
|
||||
expect(reader.read(key("p"), "page", 0)).toBeNull();
|
||||
reader.read(key("g"), "page", 0);
|
||||
expect(reader.read(key("p"), "page", SEQUENCE_TIMEOUT_MS + 1)).toBeNull();
|
||||
});
|
||||
|
||||
it("is not read from a field, under a modifier or over another dialog", () => {
|
||||
const reader = createShortcutReader();
|
||||
reader.read(key("g"), "page", 0);
|
||||
expect(reader.read(key("p", { target: input }), "page", 10)).toBeNull();
|
||||
reader.read(key("g"), "page", 20);
|
||||
expect(reader.read(key("p", { metaKey: true }), "page", 30)).toBeNull();
|
||||
reader.read(key("g"), "page", 40);
|
||||
expect(reader.read(key("p"), "off", 50)).toBeNull();
|
||||
expect(reader.read(key("g"), "off", 60)).toBeNull();
|
||||
expect(reader.read(key("p"), "page", 70)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("scope", () => {
|
||||
it("an open dialog, menu or popover takes every key", () => {
|
||||
const reader = createShortcutReader();
|
||||
@@ -191,7 +227,7 @@ describe("scope", () => {
|
||||
expect(reader.read(key("d"), "off", 10)).toBeNull();
|
||||
});
|
||||
|
||||
it("over the day sheet only [ and ] act", () => {
|
||||
it("over the day sheet only [, ] and g p act", () => {
|
||||
const reader = createShortcutReader();
|
||||
expect(reader.read(key("["), "day-sheet", 0)).toEqual({
|
||||
type: "day",
|
||||
@@ -202,7 +238,14 @@ describe("scope", () => {
|
||||
delta: 1,
|
||||
});
|
||||
expect(reader.read(key("n"), "day-sheet", 0)).toBeNull();
|
||||
expect(reader.read(key("g"), "day-sheet", 0)).toBeNull();
|
||||
expect(reader.read(key("?"), "day-sheet", 0)).toBeNull();
|
||||
// g waits, but only p completes it there: no page leaves under a sheet.
|
||||
expect(reader.read(key("g"), "day-sheet", 0)).toBe("pending");
|
||||
expect(reader.read(key("d"), "day-sheet", 10)).toBeNull();
|
||||
reader.read(key("g"), "day-sheet", 20);
|
||||
expect(reader.read(key("p"), "day-sheet", 30)).toEqual({
|
||||
type: "day-toggle",
|
||||
});
|
||||
});
|
||||
|
||||
it("Escape is never ours", () => {
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
* Option+5 on a German Mac, AltGr+8 on a German PC) still counts, because
|
||||
* for that person there is no other way to press it;
|
||||
* - while a dialog, sheet, menu or popover is open the keys are its own,
|
||||
* except over the day sheet, where `[` and `]` step the day it shows.
|
||||
* except over the day sheet, where `[` and `]` step the day it shows and
|
||||
* `g p` closes it.
|
||||
*
|
||||
* `g` starts a two-key sequence; the second key has to follow within
|
||||
* `SEQUENCE_TIMEOUT_MS`. Escape is never handled here: every surface closes
|
||||
@@ -43,17 +44,25 @@ export const GO_TO_SHORTCUTS = [
|
||||
|
||||
export type GoToKey = (typeof GO_TO_SHORTCUTS)[number]["key"];
|
||||
|
||||
/**
|
||||
* `g` + `p` opens or closes the day, the way a click on the day's strip does
|
||||
* (a sheet below the docking width). Not a destination, and no `g` key above.
|
||||
*/
|
||||
export const DAY_TOGGLE_KEY = "p";
|
||||
|
||||
export type ShortcutAction =
|
||||
| { type: "go"; key: GoToKey }
|
||||
| { type: "palette" }
|
||||
| { type: "capture" }
|
||||
| { type: "help" }
|
||||
| { type: "day"; delta: -1 | 1 };
|
||||
| { type: "day"; delta: -1 | 1 }
|
||||
| { type: "day-toggle" };
|
||||
|
||||
/**
|
||||
* Where the keyboard is:
|
||||
* - `page`: nothing modal is open, every shortcut applies;
|
||||
* - `day-sheet`: the day sheet is the only modal surface, `[` / `]` apply;
|
||||
* - `day-sheet`: the day sheet is the only modal surface, `[` / `]` and
|
||||
* `g p` apply;
|
||||
* - `off`: another dialog, sheet, menu or popover has the keys.
|
||||
*/
|
||||
export type ShortcutScope = "page" | "day-sheet" | "off";
|
||||
@@ -205,12 +214,23 @@ export function createShortcutReader(timeoutMs = SEQUENCE_TIMEOUT_MS) {
|
||||
if (key === "[" || key === "]") {
|
||||
return { type: "day", delta: key === "[" ? -1 : 1 };
|
||||
}
|
||||
if (scope !== "page") return null;
|
||||
|
||||
if (pending !== null && now - pending <= timeoutMs) {
|
||||
// The second key of a sequence: a destination, or nothing at all, so
|
||||
// a mistyped `g n` does not open the add menu.
|
||||
const inSequence = pending !== null && now - pending <= timeoutMs;
|
||||
if (scope !== "page") {
|
||||
// Over the day sheet only `g p` (which closes it) is a sequence.
|
||||
if (inSequence && key === DAY_TOGGLE_KEY) return { type: "day-toggle" };
|
||||
if (key === "g") {
|
||||
pendingSince = now;
|
||||
return "pending";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (inSequence) {
|
||||
// The second key of a sequence: a destination, the day, or nothing at
|
||||
// all, so a mistyped `g n` does not open the add menu.
|
||||
if (isGoToKey(key)) return { type: "go", key };
|
||||
if (key === DAY_TOGGLE_KEY) return { type: "day-toggle" };
|
||||
if (key !== "g") return null;
|
||||
}
|
||||
if (key === "g") {
|
||||
|
||||
Reference in new issue
Block a user