@lovett/ui 0.0.8 → 0.0.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/modal.tsx CHANGED
@@ -2,6 +2,8 @@ import {
2
2
  Children,
3
3
  isValidElement,
4
4
  useEffect,
5
+ useId,
6
+ useRef,
5
7
  type HTMLAttributes,
6
8
  type ReactElement,
7
9
  type ReactNode,
@@ -36,24 +38,167 @@ import { cn } from './lib/utils'
36
38
  * <Button>Confirm</Button>
37
39
  * </Modal.Footer>
38
40
  * </Modal>
41
+ *
42
+ * Accessibility. The panel is a `role="dialog" aria-modal="true"` region
43
+ * named by its own `<h2>` (`aria-labelledby`), or by the `ariaLabel` prop
44
+ * when a caller renders a titleless modal. Opening moves focus to the
45
+ * panel — so a screen reader announces the dialog's name — and closing
46
+ * RESTORES focus to whatever was focused before it opened. Tab and
47
+ * Shift+Tab wrap inside the panel; Escape closes.
48
+ *
49
+ * Callers that want a specific control focused on open mark it
50
+ * `data-autofocus`; anything else and the panel itself takes focus.
51
+ *
52
+ * WHY A FOCUS TRAP AND NOT `inert` ON THE APP ROOT. Modal renders INLINE
53
+ * in the React tree — it is a descendant of `#root`, not a portal — so
54
+ * `inert` on the app root would make the modal itself inert. Portaling it
55
+ * to `document.body` to unlock that would move all 59 consuming files off
56
+ * the `.cs-frame`-scoped token layer and out of their current stacking /
57
+ * inheritance context, which is a much larger change than an a11y fix
58
+ * should make. Inerting `document.body`'s other children is also wrong:
59
+ * `DropdownMenu`, `TagChipInput` and `FolderTreePicker` portal their
60
+ * content THERE, so a select inside a modal would go dead. Focus trap it
61
+ * is — the keyboard hole is closed; pointer and AT virtual-cursor access
62
+ * to the background remains, which is the documented residual.
63
+ */
64
+
65
+ /**
66
+ * Open panels, outermost first. Only the topmost one answers Escape and
67
+ * owns the tab ring, so nested modals do not both close on one Escape
68
+ * and do not fight over focus.
69
+ */
70
+ const openPanels: HTMLElement[] = []
71
+
72
+ /**
73
+ * Tabbable candidates. Deliberately attribute-only — no geometry check.
74
+ * `offsetWidth` / `getClientRects()` are always zero in jsdom, so a
75
+ * visibility filter would empty this list under test and silently
76
+ * disable the trap in exactly the environment that verifies it.
39
77
  */
78
+ const FOCUSABLE_SELECTOR = [
79
+ 'a[href]',
80
+ 'area[href]',
81
+ 'button:not([disabled])',
82
+ 'input:not([disabled]):not([type="hidden"])',
83
+ 'select:not([disabled])',
84
+ 'textarea:not([disabled])',
85
+ 'iframe',
86
+ 'summary',
87
+ '[contenteditable="true"]',
88
+ '[tabindex]',
89
+ ].join(',')
90
+
91
+ function tabbablesWithin(panel: HTMLElement): HTMLElement[] {
92
+ return Array.from(panel.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(
93
+ (el) =>
94
+ el.tabIndex >= 0 &&
95
+ !el.hasAttribute('inert') &&
96
+ el.getAttribute('aria-hidden') !== 'true' &&
97
+ !el.closest('[hidden]'),
98
+ )
99
+ }
40
100
 
41
101
  interface ModalProps {
42
102
  isOpen: boolean
43
103
  onClose: () => void
44
104
  title: string
45
105
  children: ReactNode
46
- className?: string
106
+ className?: string | undefined
107
+ /**
108
+ * Accessible name for a modal rendered with an empty `title`. Ignored
109
+ * when `title` is non-empty — the `<h2>` names the dialog then.
110
+ */
111
+ ariaLabel?: string | undefined
47
112
  }
48
113
 
49
- function Modal({ isOpen, onClose, title, children, className }: ModalProps) {
114
+ function Modal({ isOpen, onClose, title, children, className, ariaLabel }: ModalProps) {
115
+ const panelRef = useRef<HTMLDivElement | null>(null)
116
+ const titleId = useId()
117
+
118
+ // Focus: move in on open, restore on close. Keyed on `isOpen` ALONE —
119
+ // adding `onClose` here would re-run it on every render for the many
120
+ // callers that pass an inline arrow, snatching focus back to the panel
121
+ // mid-keystroke.
122
+ useEffect(() => {
123
+ if (!isOpen) return
124
+ const panel = panelRef.current
125
+ if (!panel) return
126
+
127
+ const previous =
128
+ document.activeElement instanceof HTMLElement &&
129
+ document.activeElement !== document.body
130
+ ? document.activeElement
131
+ : null
132
+
133
+ openPanels.push(panel)
134
+ const initial = panel.querySelector<HTMLElement>('[data-autofocus]')
135
+ ;(initial ?? panel).focus()
136
+
137
+ return () => {
138
+ const at = openPanels.indexOf(panel)
139
+ if (at !== -1) openPanels.splice(at, 1)
140
+ // Restoring is the half that gets forgotten: without it, closing a
141
+ // modal drops the caret to <body> and the next Tab restarts from
142
+ // the top of the page.
143
+ if (previous && previous.isConnected) previous.focus()
144
+ }
145
+ }, [isOpen])
146
+
147
+ // Escape + tab trap. One document listener; the topmost-panel guard
148
+ // keeps nested modals from both reacting.
50
149
  useEffect(() => {
51
150
  if (!isOpen) return
52
- const handleEscape = (e: KeyboardEvent) => {
53
- if (e.key === 'Escape') onClose()
151
+ const panel = panelRef.current
152
+ if (!panel) return
153
+
154
+ const handleKeyDown = (e: KeyboardEvent) => {
155
+ if (openPanels[openPanels.length - 1] !== panel) return
156
+
157
+ if (e.key === 'Escape') {
158
+ onClose()
159
+ return
160
+ }
161
+ if (e.key !== 'Tab') return
162
+
163
+ const active = document.activeElement
164
+ // Focus is outside the panel — almost always a body-level portal
165
+ // (DropdownMenu / TagChipInput / FolderTreePicker) opened FROM the
166
+ // modal. Wrapping it back in would break that menu's own keyboard
167
+ // handling, so leave the event alone.
168
+ if (!(active instanceof HTMLElement) || !panel.contains(active)) return
169
+
170
+ const tabbables = tabbablesWithin(panel)
171
+ const first = tabbables[0]
172
+ const last = tabbables[tabbables.length - 1]
173
+ if (!first || !last) {
174
+ // Nothing to land on — keep focus on the panel rather than let
175
+ // Tab walk out into the page behind the backdrop.
176
+ e.preventDefault()
177
+ panel.focus()
178
+ return
179
+ }
180
+
181
+ // The panel holds focus itself right after open. Forward Tab can
182
+ // fall through to `first` naturally; Shift+Tab would leave the
183
+ // dialog, so it wraps to `last`.
184
+ if (active === panel) {
185
+ if (e.shiftKey) {
186
+ e.preventDefault()
187
+ last.focus()
188
+ }
189
+ return
190
+ }
191
+ if (e.shiftKey && active === first) {
192
+ e.preventDefault()
193
+ last.focus()
194
+ } else if (!e.shiftKey && active === last) {
195
+ e.preventDefault()
196
+ first.focus()
197
+ }
54
198
  }
55
- document.addEventListener('keydown', handleEscape)
56
- return () => document.removeEventListener('keydown', handleEscape)
199
+
200
+ document.addEventListener('keydown', handleKeyDown)
201
+ return () => document.removeEventListener('keydown', handleKeyDown)
57
202
  }, [isOpen, onClose])
58
203
 
59
204
  if (!isOpen) return null
@@ -69,8 +214,17 @@ function Modal({ isOpen, onClose, title, children, className }: ModalProps) {
69
214
  {/* Outer card — uses .ds-card-surface so the modal gets the
70
215
  same chrome as cards (border + multi-layer shadow + inner
71
216
  highlight in light mode). overflow-hidden clips children to
72
- the rounded corners; the tray inside handles its own scroll. */}
217
+ the rounded corners; the tray inside handles its own scroll.
218
+
219
+ tabIndex={-1} makes the panel programmatically focusable so it
220
+ can receive focus on open without joining the tab ring. */}
73
221
  <div
222
+ ref={panelRef}
223
+ role="dialog"
224
+ aria-modal="true"
225
+ aria-labelledby={title ? titleId : undefined}
226
+ aria-label={title ? undefined : ariaLabel}
227
+ tabIndex={-1}
74
228
  data-framed="true"
75
229
  className={cn(
76
230
  'ds-card-surface relative w-full max-w-2xl flex flex-col max-h-[90vh] overflow-hidden',
@@ -89,7 +243,10 @@ function Modal({ isOpen, onClose, title, children, className }: ModalProps) {
89
243
  than the text, the BUTTON set the row height and the title
90
244
  floated in slack it never asked for. */}
91
245
  <div className="flex items-center justify-between gap-3 shrink-0 px-4 py-3">
92
- <h2 className="text-[15px] font-bold tracking-[-0.01em] text-[rgb(var(--foreground))]">
246
+ <h2
247
+ id={titleId}
248
+ className="text-[15px] font-bold tracking-[-0.01em] text-[rgb(var(--foreground))]"
249
+ >
93
250
  {title}
94
251
  </h2>
95
252
  <button
@@ -10,6 +10,7 @@ import {
10
10
  } from 'react'
11
11
  import { createPortal } from 'react-dom'
12
12
  import { Link } from 'react-router-dom'
13
+ import { House } from 'lucide-react'
13
14
  import { cn } from './lib/utils'
14
15
 
15
16
  export type Crumb = {
@@ -60,7 +61,24 @@ interface PageShellProps {
60
61
  // per mount, never per render).
61
62
  // ---------------------------------------------------------------------------
62
63
 
64
+ /**
65
+ * The app's home destination, rendered as the first breadcrumb.
66
+ *
67
+ * PER-APP AND OPT-IN, which is the whole reason it lives on the provider
68
+ * instead of being hardcoded in `Breadcrumbs`. `PageShell` is shared: the
69
+ * workspace and `apps/approvals` both render it, and approvals has no
70
+ * `/clients` route — a hardcoded home would have shipped it a dead link on
71
+ * every page. An app that wants a home crumb says where home is; an app that
72
+ * does not gets no icon and nothing breaks.
73
+ */
74
+ export interface HomeCrumb {
75
+ to: string
76
+ /** Accessible name for the icon-only link, e.g. "Clients". */
77
+ label: string
78
+ }
79
+
63
80
  interface PageHeaderSlotValue {
81
+ home: HomeCrumb | null
64
82
  hostEl: HTMLElement | null
65
83
  centerEl: HTMLElement | null
66
84
  scrollEl: HTMLElement | null
@@ -71,7 +89,14 @@ interface PageHeaderSlotValue {
71
89
 
72
90
  const PageHeaderSlotContext = createContext<PageHeaderSlotValue | null>(null)
73
91
 
74
- export function PageHeaderSlotProvider({ children }: { children: ReactNode }) {
92
+ export function PageHeaderSlotProvider({
93
+ children,
94
+ home = null,
95
+ }: {
96
+ children: ReactNode
97
+ /** Where the leading home crumb points. Omit to render no home crumb. */
98
+ home?: HomeCrumb | null
99
+ }) {
75
100
  const [hostEl, setHostEl] = useState<HTMLElement | null>(null)
76
101
  const [centerEl, setCenterEl] = useState<HTMLElement | null>(null)
77
102
  const [scrollEl, setScrollEl] = useState<HTMLElement | null>(null)
@@ -84,6 +109,7 @@ export function PageHeaderSlotProvider({ children }: { children: ReactNode }) {
84
109
  return (
85
110
  <PageHeaderSlotContext.Provider
86
111
  value={{
112
+ home,
87
113
  hostEl,
88
114
  centerEl,
89
115
  scrollEl,
@@ -375,6 +401,50 @@ export function PageHeaderHost({
375
401
  )
376
402
  }
377
403
 
404
+ /**
405
+ * The app's home destination, or null when the app has not declared one.
406
+ *
407
+ * Exported because not every breadcrumb in the app goes through `PageShell` —
408
+ * the Generate lens renders its own trail beside an editable project title —
409
+ * and those forks should read the same destination rather than each hardcoding
410
+ * a path that then drifts.
411
+ */
412
+ export function useBreadcrumbHome(): HomeCrumb | null {
413
+ return useContext(PageHeaderSlotContext)?.home ?? null
414
+ }
415
+
416
+ /**
417
+ * The leading home crumb: an icon, not a word.
418
+ *
419
+ * WHY AN ICON REPLACES THE FIRST CRUMB RATHER THAN SITTING BESIDE IT. Thirty-one
420
+ * lens trails already began `Clients / …` pointing at `/clients`, which is
421
+ * exactly where home goes — so rendering both would put the same destination on
422
+ * screen twice in a row, once as a picture and once as a word. The icon takes
423
+ * that crumb's place when they agree and prepends when they do not (the tools
424
+ * trails start at `/tools`), so "first position" holds either way.
425
+ *
426
+ * It carries an `aria-label` and a `title`: an icon-only link has no accessible
427
+ * name otherwise, and the tooltip is what tells a first-time user that the house
428
+ * means the client list rather than some other notion of home.
429
+ */
430
+ export function HomeCrumbLink({ home }: { home: HomeCrumb }) {
431
+ return (
432
+ <Link
433
+ to={home.to}
434
+ aria-label={home.label}
435
+ title={home.label}
436
+ className="shrink-0 flex items-center transition-colors hover:text-[rgb(var(--foreground))]"
437
+ style={{ color: 'inherit' }}
438
+ >
439
+ {/* 15px beside 13px crumb text, at the 1.9 stroke the theme toggle in
440
+ this same header band uses — the app's chrome runs heavier than
441
+ Lucide's default, and matching the neighbour matters more than the
442
+ generic rule. */}
443
+ <House className="h-[15px] w-[15px]" strokeWidth={1.9} aria-hidden="true" />
444
+ </Link>
445
+ )
446
+ }
447
+
378
448
  function Breadcrumbs({
379
449
  parents,
380
450
  current,
@@ -382,12 +452,31 @@ function Breadcrumbs({
382
452
  parents: Crumb[]
383
453
  current: Crumb | undefined
384
454
  }) {
455
+ const home = useBreadcrumbHome()
456
+ // When the first crumb already IS home, the icon stands in for it rather
457
+ // than duplicating it. Compared by destination, not by label, because the
458
+ // label varies ("Clients", a brand name) while the route does not.
459
+ const visibleParents =
460
+ home && parents[0]?.to === home.to ? parents.slice(1) : parents
461
+
385
462
  return (
386
463
  <nav
387
464
  className="flex items-center gap-1.5 text-[13px] min-w-0"
388
465
  style={{ color: 'rgb(var(--text-tertiary))' }}
389
466
  >
390
- {parents.map((c, i) => (
467
+ {home && (
468
+ <>
469
+ <HomeCrumbLink home={home} />
470
+ {/* Separator only when something follows it. A page with no crumbs
471
+ at all would otherwise render a dangling "home /". */}
472
+ {(visibleParents.length > 0 || current) && (
473
+ <span className="shrink-0" style={{ color: 'rgb(var(--text-muted))' }}>
474
+ /
475
+ </span>
476
+ )}
477
+ </>
478
+ )}
479
+ {visibleParents.map((c, i) => (
391
480
  <span key={i} className="flex items-center gap-1.5 min-w-0">
392
481
  {c.to ? (
393
482
  <Link
@@ -9,12 +9,20 @@
9
9
  * │ └──────────────┘ │
10
10
  * ╰──────────────────────────────────────────────────────────────╯
11
11
  *
12
- * • Whole strip sits in a subtle `--surface-overlay-soft` rounded
13
- * container so the inactive segments read as text rather than
14
- * borderless buttons.
15
- * The active segment is wrapped in a 1px accent border with an
16
- * `--accent-subtle` fill reads as an "outlined pill" sitting
17
- * inside the strip.
12
+ * • The track is an OPAQUE recessed well (`--surface-frame`) so the
13
+ * inactive segments read as text rather than borderless buttons.
14
+ * The active segment steps UP off the track: an opaque
15
+ * `--surface-card` fill sandwiched between the track and a 1px
16
+ * accent border, plus `--shadow-sm`. It reads as a raised pill,
17
+ * not a tinted one.
18
+ *
19
+ * Both were translucent until 2026-08-30 (`--surface-overlay-soft`
20
+ * track, `--accent-subtle` pill). Stacked, they composited to a 245
21
+ * pill inside a 247 track in light mode — the active segment was
22
+ * DARKER than the thing it sits in, so the raised element read as
23
+ * recessed and the control looked washed out. Opaque surfaces cannot
24
+ * invert like that, because their relationship does not depend on
25
+ * whatever happens to be behind them.
18
26
  * • Each segment is a button with an optional leading icon.
19
27
  *
20
28
  * Generic — accepts a typed `items` array. First consumer: Keywords
@@ -79,7 +87,14 @@ export function SegmentedPill<Id extends string = string>({
79
87
  className,
80
88
  )}
81
89
  style={{
82
- background: 'rgb(var(--surface-overlay-soft))',
90
+ // OPAQUE. This was --surface-overlay-soft, a translucent tint, with a
91
+ // translucent --accent-subtle pill on top of it — two alpha layers
92
+ // stacked. In light that composited to a 245 pill inside a 247 strip:
93
+ // the ACTIVE segment rendered DARKER than the track it sits in, so the
94
+ // raised element read as recessed and the whole control looked washed
95
+ // out. --surface-frame is the recessed-well token and is darker than
96
+ // --surface-card in BOTH themes, so the direction holds either way.
97
+ background: 'rgb(var(--surface-frame))',
83
98
  border: '1px solid rgb(var(--border))',
84
99
  borderRadius: 'var(--radius-full)',
85
100
  }}
@@ -110,9 +125,17 @@ export function SegmentedPill<Id extends string = string>({
110
125
  borderRadius: 'var(--radius-full)',
111
126
  ...(isActive
112
127
  ? {
113
- background: 'rgb(var(--accent-subtle))',
128
+ // The active segment STEPS UP off the track: an opaque
129
+ // surface sandwiched between the accent border and the
130
+ // strip, plus a small shadow. --surface-card is +8 on the
131
+ // frame in light and +6 in dark, so it reads raised in both.
132
+ background: 'rgb(var(--surface-card))',
114
133
  border: '1px solid rgb(var(--accent))',
115
- color: 'rgb(var(--accent))',
134
+ // --accent-ink, not --accent: the brand red is 5.64:1 on a
135
+ // light card but 3.05:1 on a dark one, i.e. an accent LABEL
136
+ // fails AA in dark. --accent-ink is the theme-tuned form.
137
+ color: 'rgb(var(--accent-ink))',
138
+ boxShadow: 'var(--shadow-sm)',
116
139
  }
117
140
  : {
118
141
  background: 'transparent',
@@ -126,7 +149,7 @@ export function SegmentedPill<Id extends string = string>({
126
149
  className="inline-flex shrink-0"
127
150
  style={{
128
151
  color: isActive
129
- ? 'rgb(var(--accent))'
152
+ ? 'rgb(var(--accent-ink))'
130
153
  : 'rgb(var(--text-muted))',
131
154
  }}
132
155
  aria-hidden="true"
package/src/styles.css CHANGED
@@ -131,6 +131,34 @@
131
131
  box-shadow: var(--ring-focus);
132
132
  }
133
133
 
134
+ /* ─────────────────────────────────────────────────────────────────────────
135
+ * BASELINE FOCUS INDICATOR
136
+ *
137
+ * Until this existed, only elements that opted in — primitives via .btn /
138
+ * .input-shell, and hand-written `focus-visible:` classes — showed OUR focus
139
+ * ring. Everything else fell through to the browser's `outline: auto`, which
140
+ * on macOS paints in the USER'S SYSTEM ACCENT COLOUR. So a keyboard user with
141
+ * an amber system accent saw an amber ring on most controls and our red ring
142
+ * on a few, in the same tab sequence. Measured in the Generate lens: 115
143
+ * buttons, 35 with a ring.
144
+ *
145
+ * `:where()` gives this ZERO specificity, so it is a floor, not an override —
146
+ * .btn, .input-shell, and any component or utility class still win.
147
+ * ───────────────────────────────────────────────────────────────────────── */
148
+ @layer base {
149
+ :where(a[href], button, input, select, textarea, summary, [tabindex]:not([tabindex^='-'])):focus-visible {
150
+ /* OUTLINE, not box-shadow, deliberately. Tailwind's shadow/ring utilities
151
+ compose onto `box-shadow`, so a zero-specificity box-shadow baseline loses
152
+ to any element carrying one (e.g. a `ring-1` selected state) and silently
153
+ renders nothing. `outline` is untouched by that chain, follows
154
+ border-radius in every modern browser, and matches --ring-focus's geometry.
155
+ Elements with their own ring set `outline: none` alongside it, so they
156
+ override this cleanly rather than double-drawing. */
157
+ outline: 2px solid rgb(var(--accent));
158
+ outline-offset: 0;
159
+ }
160
+ }
161
+
134
162
  .btn-primary {
135
163
  background: rgb(var(--accent));
136
164
  color: white;