@lovett/ui 0.0.9 → 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/dist/index.d.ts +191 -156
- package/dist/index.js +227 -28
- package/dist/index.js.map +1 -1
- package/dist/styles.css +28 -0
- package/dist/tokens.css +114 -8
- package/package.json +1 -1
- package/src/__tests__/modal.test.tsx +180 -6
- package/src/code-block.tsx +15 -26
- package/src/icons/brand.tsx +81 -2
- package/src/index.ts +5 -0
- package/src/modal.tsx +165 -8
- package/src/page-shell.tsx +91 -2
- package/src/segmented-pill.tsx +33 -10
- package/src/styles.css +28 -0
- package/src/theme-v2.css +7 -245
- package/src/tokens.css +114 -8
- package/src/react-syntax-highlighter-prism.d.ts +0 -34
- package/src/v2/README.md +0 -208
- package/src/v2/__demo__/showcase.tsx +0 -1045
- package/src/v2/action.tsx +0 -91
- package/src/v2/callout.tsx +0 -76
- package/src/v2/document-section.tsx +0 -82
- package/src/v2/document-shell.tsx +0 -0
- package/src/v2/field-row.tsx +0 -113
- package/src/v2/icons.tsx +0 -165
- package/src/v2/index.ts +0 -147
- package/src/v2/layout.tsx +0 -293
- package/src/v2/progress-track.tsx +0 -89
- package/src/v2/stat-tile.tsx +0 -129
- package/src/v2/states.tsx +0 -271
- package/src/v2/status-pill.tsx +0 -74
- package/src/v2/theme.css +0 -1861
- package/src/v2/timeline.tsx +0 -81
- package/src/v2/tokens.ts +0 -228
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
|
|
53
|
-
|
|
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
|
-
|
|
56
|
-
|
|
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
|
|
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
|
package/src/page-shell.tsx
CHANGED
|
@@ -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({
|
|
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
|
-
{
|
|
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
|
package/src/segmented-pill.tsx
CHANGED
|
@@ -9,12 +9,20 @@
|
|
|
9
9
|
* │ └──────────────┘ │
|
|
10
10
|
* ╰──────────────────────────────────────────────────────────────╯
|
|
11
11
|
*
|
|
12
|
-
* •
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* `--
|
|
17
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|