|
7 | 7 | * (inside an app, from `lg` up: the search is centered) |
8 | 8 | * |
9 | 9 | * Variants: |
10 | | - * - `app` (default when `appName` is present): sidebar trigger + AppSwitcher |
11 | | - * + the platform-stage chip. Used by `ConsoleLayout` inside |
12 | | - * `/apps/:appName/*`. The bar starts with the app pill — the |
13 | | - * app's glyph and name — and carries no platform mark: one brand |
14 | | - * mark per screen, and inside an app that mark is the app's |
15 | | - * (objectui#12201). An object route (its list, a view, a record) |
| 10 | + * - `app` (default when `appName` is present): sidebar trigger + the |
| 11 | + * product mark + a `/` rule + AppSwitcher + the platform-stage |
| 12 | + * chip. Used by `ConsoleLayout` inside `/apps/:appName/*`. The |
| 13 | + * product mark stays in the bar as the way home, the same mark |
| 14 | + * the home page draws, and `⌘⇧H` / `Ctrl+Shift+H` goes home too |
| 15 | + * (objectui#12605, which reverses objectui#12201's "no platform |
| 16 | + * mark inside an app"). An object route (its list, a view, a record) |
16 | 17 | * draws no crumb after the pill: the page header is the one place |
17 | 18 | * that page is named (objectui#12461). A dashboard or page route |
18 | 19 | * draws none either (objectui#12526). A report or `system/*` |
@@ -103,6 +104,7 @@ import { resolveViewId } from '../utils/resolveViewId.js'; |
103 | 104 | import { getProductName, getLogoUrl } from '../runtime-config.js'; |
104 | 105 | import { LocalizedSidebarTrigger } from './LocalizedSidebarTrigger.js'; |
105 | 106 | import { PreviewBadge } from './PreviewBadge.js'; |
| 107 | +import { GO_HOME_SHORTCUT, useAdvertiseShortcut, type ShortcutChord } from '../chrome/advertisedShortcuts.js'; |
106 | 108 |
|
107 | 109 | function humanizeSlug(slug: string): string { |
108 | 110 | return slug.replace(/[-_]/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); |
@@ -138,6 +140,88 @@ function PathSep() { |
138 | 140 | ); |
139 | 141 | } |
140 | 142 |
|
| 143 | +/** |
| 144 | + * The product mark — the way home (objectui#12605). One element for every |
| 145 | + * surface that draws it: the home page, the organizations and Studio front |
| 146 | + * doors, and inside every app after the sidebar trigger. A 32 px target |
| 147 | + * holding a 24 px tile on `--primary` with the stack glyph, or the |
| 148 | + * deployment's logo. Named 「PRODUCT home」; `title` keeps the product's name. |
| 149 | + * |
| 150 | + * `homePath`, not a literal `/home` (objectui#7256): on a deployment that |
| 151 | + * DECLARES a landing this is the declared one, so the mark and the post-login |
| 152 | + * landing name the same screen. |
| 153 | + */ |
| 154 | +function ProductHomeMark({ homePath, label }: { homePath: string; label: string }) { |
| 155 | + const productName = getProductName(); |
| 156 | + const logoUrl = getLogoUrl(); |
| 157 | + return ( |
| 158 | + <Link |
| 159 | + to={homePath} |
| 160 | + data-testid="app-bar-product-home" |
| 161 | + aria-label={label} |
| 162 | + title={productName} |
| 163 | + className="flex size-8 shrink-0 items-center justify-center rounded-md hover:bg-accent transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring" |
| 164 | + > |
| 165 | + <span className="flex size-6 items-center justify-center overflow-hidden rounded-md bg-primary text-primary-foreground"> |
| 166 | + {logoUrl ? ( |
| 167 | + <img src={logoUrl} alt="" className="h-full w-full object-contain" /> |
| 168 | + ) : ( |
| 169 | + <Layers className="size-3.5" aria-hidden /> |
| 170 | + )} |
| 171 | + </span> |
| 172 | + </Link> |
| 173 | + ); |
| 174 | +} |
| 175 | + |
| 176 | +/** |
| 177 | + * Whether a keydown is `chord`: ⌘ or Ctrl exactly when the chord has `mod` |
| 178 | + * (the advertised chords accept either), Shift exactly when it has `shift`, |
| 179 | + * never Alt, and the key compared case-blind (Shift makes it upper case). |
| 180 | + */ |
| 181 | +function matchesShortcutChord( |
| 182 | + e: Pick<KeyboardEvent, 'key' | 'metaKey' | 'ctrlKey' | 'shiftKey' | 'altKey'>, |
| 183 | + chord: ShortcutChord, |
| 184 | +): boolean { |
| 185 | + if (e.altKey) return false; |
| 186 | + if (Boolean(chord.mod) !== (e.metaKey || e.ctrlKey)) return false; |
| 187 | + if (Boolean(chord.shift) !== e.shiftKey) return false; |
| 188 | + return e.key.toLowerCase() === chord.key; |
| 189 | +} |
| 190 | + |
| 191 | +/** |
| 192 | + * A keydown aimed at something the user types into: a text field, a select, |
| 193 | + * a content-editable region (rich-text and code editors), or an ARIA textbox. |
| 194 | + * The home chord leaves those alone, so an editor that binds the same keys |
| 195 | + * keeps them (objectui#12605). |
| 196 | + */ |
| 197 | +function isTypingTarget(target: EventTarget | null): boolean { |
| 198 | + if (!(target instanceof Element)) return false; |
| 199 | + if (target instanceof HTMLElement && target.isContentEditable) return true; |
| 200 | + return target.closest('input, textarea, select, [contenteditable]:not([contenteditable="false"]), [role="textbox"]') !== null; |
| 201 | +} |
| 202 | + |
| 203 | +/** |
| 204 | + * `⌘⇧H` / `Ctrl+Shift+H` goes home from any app page, as a PUSH so the |
| 205 | + * browser's Back returns to the page (objectui#12605). Advertised beside the |
| 206 | + * listener, under the same gate, so the keyboard-shortcuts dialog lists it |
| 207 | + * exactly where it fires (objectui#11674). |
| 208 | + */ |
| 209 | +function useGoHomeShortcut(enabled: boolean, homePath: string): void { |
| 210 | + const navigate = useNavigate(); |
| 211 | + useEffect(() => { |
| 212 | + if (!enabled) return undefined; |
| 213 | + const onKeyDown = (e: KeyboardEvent) => { |
| 214 | + if (e.defaultPrevented || e.isComposing) return; |
| 215 | + if (!matchesShortcutChord(e, GO_HOME_SHORTCUT.chord)) return; |
| 216 | + if (isTypingTarget(e.target)) return; |
| 217 | + e.preventDefault(); |
| 218 | + navigate(homePath); |
| 219 | + }; |
| 220 | + document.addEventListener('keydown', onKeyDown); |
| 221 | + return () => document.removeEventListener('keydown', onKeyDown); |
| 222 | + }, [enabled, homePath, navigate]); |
| 223 | + useAdvertiseShortcut(GO_HOME_SHORTCUT, enabled); |
| 224 | +} |
141 | 225 |
|
142 | 226 | // No fake fallback presence — render nothing when the API has no data so the |
143 | 227 | // header doesn't ship phantom collaborators in production. |
@@ -175,6 +259,7 @@ export function AppHeader({ |
175 | 259 | // objectui#7256 — the brand logo's target follows the product's DECLARED |
176 | 260 | // landing, so the chrome cannot offer a second, contradicting "home". |
177 | 261 | const homePath = useHomePath(); |
| 262 | + useGoHomeShortcut(isApp, homePath); |
178 | 263 | const { isOnline } = useOffline(); |
179 | 264 | // Idempotent, direct open of the ⌘K command palette (ADR-0054 C1). Replaces a |
180 | 265 | // synthetic `⌘K` KeyboardEvent re-dispatch that did nothing under automation. |
@@ -534,31 +619,35 @@ export function AppHeader({ |
534 | 619 | sidebar and its icon rail above that. It is the bar's leftmost |
535 | 620 | control inside an app, so it never moves when the sidebar |
536 | 621 | collapses; the sidebar itself carries no trigger. */} |
537 | | - {isApp && ( |
| 622 | + {isApp ? ( |
538 | 623 | <LocalizedSidebarTrigger className="shrink-0 mr-1" aria-label={t('common.toggleSidebar', { defaultValue: 'Toggle sidebar' })} /> |
| 624 | + ) : ( |
| 625 | + // objectui#12605 — the product mark sits at the same x on every |
| 626 | + // surface. Off an app there is no sidebar to toggle, so the |
| 627 | + // trigger's slot (its 28 px box and its gap) is held empty: the |
| 628 | + // mark does not jump left when the viewer goes home. |
| 629 | + <span aria-hidden data-testid="app-bar-trigger-slot" className="h-7 w-7 shrink-0 mr-1" /> |
539 | 630 | )} |
540 | 631 |
|
541 | | - {/* Platform logo — links to home. Platform surfaces only (Home, |
542 | | - organizations, the Studio front door): inside an app the bar |
543 | | - starts with the app pill instead, and the way back to Home is the |
544 | | - pill's 「All apps」 row (objectui#12201 items 4 and 6) — on a phone, |
545 | | - the drawer header's 「All apps」 button. |
546 | | -
|
547 | | - `homePath`, not a literal `/home` (objectui#7256): on a deployment |
548 | | - that DECLARES a landing this is the declared one, so the logo and |
549 | | - the post-login landing name the same screen. */} |
550 | | - {!isApp && ( |
551 | | - <Link |
552 | | - to={homePath} |
553 | | - className="flex items-center justify-center h-7 w-7 shrink-0 rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors overflow-hidden" |
554 | | - title={getProductName()} |
555 | | - > |
556 | | - {getLogoUrl() ? ( |
557 | | - <img src={getLogoUrl()} alt={getProductName()} className="h-full w-full object-contain" /> |
558 | | - ) : ( |
559 | | - <Layers className="h-4 w-4" /> |
560 | | - )} |
561 | | - </Link> |
| 632 | + {/* The product mark — links to home, on every surface: the home |
| 633 | + page, the organizations and Studio front doors, and inside every |
| 634 | + app, after the sidebar trigger (objectui#12605). Inside an app a |
| 635 | + `/` rule follows it, so the bar reads 「PRODUCT / APP」. The rule |
| 636 | + is drawn, not typed: it carries no text, and the `/` separators |
| 637 | + the crumbs after the pill use stay theirs alone. */} |
| 638 | + <ProductHomeMark |
| 639 | + homePath={homePath} |
| 640 | + label={t('layout.appSwitcher.productHome', { |
| 641 | + defaultValue: '{{product}} home', |
| 642 | + product: getProductName(), |
| 643 | + })} |
| 644 | + /> |
| 645 | + {isApp && ( |
| 646 | + <span |
| 647 | + aria-hidden |
| 648 | + data-testid="app-bar-home-separator" |
| 649 | + className="mx-1.5 h-[18px] w-px shrink-0 rotate-[20deg] bg-border" |
| 650 | + /> |
562 | 651 | )} |
563 | 652 |
|
564 | 653 | {resolvedVariant === 'home' && ( |
@@ -604,16 +693,15 @@ export function AppHeader({ |
604 | 693 |
|
605 | 694 | {isApp && ( |
606 | 695 | <> |
607 | | - {/* App pill — the app's glyph and name, and its switcher. It |
608 | | - starts the path, so no separator precedes it. Desktop/tablet |
609 | | - only: on mobile the drawer header names the active app, so a |
610 | | - second app pill in the topbar is pure noise. The dropdown's |
611 | | - Studio row takes the Studio button's own target, so the two |
612 | | - cannot disagree (objectui#12201 item 6). `min-w-0` here and |
613 | | - on the pill's button lets a long app name truncate in the |
| 696 | + {/* App pill — the app's glyph and name, and its switcher, after |
| 697 | + the product mark's `/` rule. At every width: below `sm` it is |
| 698 | + the glyph alone, named in its tooltip (objectui#12605). The |
| 699 | + dropdown's Studio row takes the Studio button's own target, so |
| 700 | + the two cannot disagree (objectui#12201 item 6). `min-w-0` here |
| 701 | + and on the pill's button lets a long app name truncate in the |
614 | 702 | pill rather than spill out of the left zone (objectui#12472). */} |
615 | 703 | {activeAppName && onAppChange ? ( |
616 | | - <div className="hidden sm:flex items-center min-w-0"> |
| 704 | + <div className="flex items-center min-w-0"> |
617 | 705 | <AppSwitcher |
618 | 706 | activeAppName={activeAppName} |
619 | 707 | onAppChange={onAppChange} |
|
0 commit comments