@kolkrabbi/kol-component 0.212.0 → 0.214.0
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kolkrabbi/kol-component",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.214.0",
|
|
4
4
|
"description": "KOL design-system components \u2014 atoms through organisms, emitting canonical kol-* classes. Pairs with @kolkrabbi/kol-theme for styling.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
package/src/index.js
CHANGED
|
@@ -143,6 +143,7 @@ export { default as Carousel } from './molecules/Carousel.jsx'
|
|
|
143
143
|
* stage reaches for it instead of re-typing the button markup, which is how the
|
|
144
144
|
* three in-package copies (and kol-website's CarouselNavigation) happened. */
|
|
145
145
|
export { default as EmblaNav } from './molecules/EmblaNav.jsx'
|
|
146
|
+
export { default as MobileTabBar, TABBAR_H } from './molecules/MobileTabBar.jsx'
|
|
146
147
|
export { default as ContentFilters } from './organisms/ContentFilters.jsx'
|
|
147
148
|
export { default as PageHeader } from './molecules/PageHeader.jsx'
|
|
148
149
|
export { default as ErrorBoundary } from './utilities/ErrorBoundary.jsx'
|
|
@@ -27,6 +27,12 @@ export function MenuItem({
|
|
|
27
27
|
panelStyle,
|
|
28
28
|
buttonClassName = '',
|
|
29
29
|
defaultOpen = false,
|
|
30
|
+
/* THE CARET IS NOT ALWAYS RIGHT. The trigger draws `label ▾`, which is the
|
|
31
|
+
* shape of a named menu — File, Sort. An ICON trigger (`···`, a gear) is
|
|
32
|
+
* already complete and a caret beside it reads as a second glyph rather than
|
|
33
|
+
* an affordance; neither reference draws one (ColumnBrowserMobileViews item
|
|
34
|
+
* 15, kol-r2b2 2026-09-04). Default keeps every existing call-site. */
|
|
35
|
+
caret = true,
|
|
30
36
|
}) {
|
|
31
37
|
const [open, setOpen] = useState(defaultOpen)
|
|
32
38
|
const popover = usePopover({
|
|
@@ -48,11 +54,13 @@ export function MenuItem({
|
|
|
48
54
|
className={`kol-helper-12 px-3 h-8 inline-flex items-center gap-2 rounded text-body hover:text-emphasis transition-colors ${buttonClassName}`}
|
|
49
55
|
>
|
|
50
56
|
<span>{label}</span>
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
57
|
+
{caret && (
|
|
58
|
+
<Icon
|
|
59
|
+
name="chevron-down"
|
|
60
|
+
size={10}
|
|
61
|
+
style={{ transform: open ? 'rotate(180deg)' : 'rotate(0deg)', transition: 'transform 200ms' }}
|
|
62
|
+
/>
|
|
63
|
+
)}
|
|
56
64
|
</button>
|
|
57
65
|
{/* w-max: floats size to CONTENT, never to the containing block —
|
|
58
66
|
* same family law as ShapeDropdown's panel (2026-08-09 review). */}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { Icon } from '@kolkrabbi/kol-icons'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* MobileTabBar — the floating bottom tab pill, below `md` only.
|
|
5
|
+
*
|
|
6
|
+
* Both references end the same way: neither iOS Files nor Dropbox stacks two
|
|
7
|
+
* full-height surfaces on a phone. Each floats a pill at the bottom and gives
|
|
8
|
+
* every surface a tab — Files `Recents · Shared · Browse`, Dropbox `Home ·
|
|
9
|
+
* Files · Photos · Account` (ColumnBrowserMobileViews §5, kol-r2b2, user-ruled
|
|
10
|
+
* 2026-09-03). It is what answers the question `ColumnBrowserStackMode` left
|
|
11
|
+
* open: the library wall does NOT stay stacked under the browser.
|
|
12
|
+
*
|
|
13
|
+
* WHAT A TAB MEANS IS THE CONSUMER'S. This ships the pill — the shape, the
|
|
14
|
+
* float, the states, the breakpoint — and takes a list. A repo whose surfaces
|
|
15
|
+
* are routes wires it to its router; one whose surfaces are variants swaps a
|
|
16
|
+
* variant. The DS deciding that a media library has exactly three surfaces
|
|
17
|
+
* called Browse, Files and Kinds is the kind of guess that makes an organism
|
|
18
|
+
* un-reusable.
|
|
19
|
+
*
|
|
20
|
+
* FLOATS, not a layout row: it sits over the list on `position: fixed` so the
|
|
21
|
+
* list scrolls under it, which is what both references do. The consumer owes
|
|
22
|
+
* the list bottom padding — `--kol-tabbar-h` is published for exactly that, so
|
|
23
|
+
* nobody hardcodes 56.
|
|
24
|
+
*
|
|
25
|
+
* @param {Array<{value: string, label: string, icon?: string}>} tabs
|
|
26
|
+
* @param {string} value the active tab
|
|
27
|
+
* @param {Function} onChange (value) => void
|
|
28
|
+
* @param {string} className extra classes on the pill
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
export const TABBAR_H = 56
|
|
32
|
+
|
|
33
|
+
export default function MobileTabBar({ tabs = [], value, onChange, className = '' }) {
|
|
34
|
+
if (tabs.length === 0) return null
|
|
35
|
+
return (
|
|
36
|
+
<nav
|
|
37
|
+
className={`kol-mobile-tabbar md:hidden fixed inset-x-0 bottom-0 z-[var(--kol-z-sticky)] flex items-stretch ${className}`.trim()}
|
|
38
|
+
style={{
|
|
39
|
+
height: TABBAR_H,
|
|
40
|
+
/* the pill's own ground, not the page's — it floats OVER content, so a
|
|
41
|
+
* transparent bar would show rows sliding through the labels */
|
|
42
|
+
background: 'var(--kol-surface-primary)',
|
|
43
|
+
borderTop: '1px solid var(--kol-oq-08)',
|
|
44
|
+
paddingBottom: 'env(safe-area-inset-bottom, 0px)',
|
|
45
|
+
}}
|
|
46
|
+
aria-label="Sections"
|
|
47
|
+
>
|
|
48
|
+
{tabs.map((t) => {
|
|
49
|
+
const active = t.value === value
|
|
50
|
+
return (
|
|
51
|
+
<button
|
|
52
|
+
key={t.value}
|
|
53
|
+
type="button"
|
|
54
|
+
onClick={() => onChange?.(t.value)}
|
|
55
|
+
aria-current={active ? 'page' : undefined}
|
|
56
|
+
className="flex-1 min-w-0 flex flex-col items-center justify-center gap-1 cursor-pointer"
|
|
57
|
+
style={{ background: 'transparent', border: 'none', color: active ? 'var(--kol-fg-default)' : 'var(--kol-fg-48)' }}
|
|
58
|
+
>
|
|
59
|
+
{t.icon && <Icon name={t.icon} size={18} />}
|
|
60
|
+
{/* helper is single-line chrome, which a tab label always is */}
|
|
61
|
+
<span className="kol-helper-10 truncate max-w-full px-1">{t.label}</span>
|
|
62
|
+
</button>
|
|
63
|
+
)
|
|
64
|
+
})}
|
|
65
|
+
</nav>
|
|
66
|
+
)
|
|
67
|
+
}
|
|
@@ -13,6 +13,9 @@ import Dropdown from '../molecules/Dropdown.jsx'
|
|
|
13
13
|
import ContentCard from '../molecules/ContentCard.jsx'
|
|
14
14
|
import ContentRow from '../molecules/ContentRow.jsx'
|
|
15
15
|
import SortControls from '../molecules/SortControls.jsx'
|
|
16
|
+
import SearchInput from '../molecules/SearchInput.jsx'
|
|
17
|
+
import MobileTabBar, { TABBAR_H } from '../molecules/MobileTabBar.jsx'
|
|
18
|
+
import { MenuItem, MenuDropdownItem, MenuDropdownDivider } from '../molecules/MenuItem.jsx'
|
|
16
19
|
import KindPreview from '../molecules/KindPreview.jsx'
|
|
17
20
|
import AudioSheet from '../molecules/AudioSheet.jsx'
|
|
18
21
|
import VideoSheet from '../molecules/VideoSheet.jsx'
|
|
@@ -81,6 +84,13 @@ const FOLDER_VIEW_OPTIONS = [
|
|
|
81
84
|
{ value: 'rows', label: 'Rows', icon: 'view-list' },
|
|
82
85
|
{ value: 'columns', label: 'Columns', icon: 'columns' },
|
|
83
86
|
]
|
|
87
|
+
/* the stack view the `···` switches (item 15) — the same two ColumnBrowser
|
|
88
|
+
* carries below `md`, named as the references name them */
|
|
89
|
+
const STACK_VIEW_OPTIONS = [
|
|
90
|
+
{ value: 'list', label: 'List' },
|
|
91
|
+
{ value: 'grid', label: 'Icons' },
|
|
92
|
+
]
|
|
93
|
+
|
|
84
94
|
const SORT_OPTIONS = [
|
|
85
95
|
{ value: 'name', label: 'Name' },
|
|
86
96
|
{ value: 'date', label: 'Date' },
|
|
@@ -328,6 +338,33 @@ const profileOf = (objects, rawFiles, systemCount) => ({
|
|
|
328
338
|
})
|
|
329
339
|
|
|
330
340
|
/* ══ BROWSE — folder / files ═══════════════════════════════════════════════ */
|
|
341
|
+
/* THE `···`'s SORT, pure and module-level so it is reachable by a check
|
|
342
|
+
* (ColumnBrowserMobileViews item 15). Sorting was desktop-only chrome until
|
|
343
|
+
* now — item 7 of the previous ticket hid the control cluster below `md` and
|
|
344
|
+
* put nothing in its place, so on a phone sort could not be reached at all.
|
|
345
|
+
*
|
|
346
|
+
* NAME IS THE TIEBREAK IN EVERY MODE. Two files of equal size, or a bucket
|
|
347
|
+
* whose objects carry no `uploaded`, otherwise come back in whatever order the
|
|
348
|
+
* comparator happened to walk — a list that reshuffles between renders for no
|
|
349
|
+
* reason the user did anything to cause. The tiebreak is deliberately NOT
|
|
350
|
+
* reversed by `sortDir`: descending by size still reads A before B inside a
|
|
351
|
+
* tie, which is what every file manager does. */
|
|
352
|
+
function sortObjects(objects, sortBy = 'name', sortDir = 'asc') {
|
|
353
|
+
const nameOf = (o) => o.key.slice(o.key.lastIndexOf('/') + 1).toLowerCase()
|
|
354
|
+
const val = {
|
|
355
|
+
name: nameOf,
|
|
356
|
+
date: (o) => o.uploaded ?? '',
|
|
357
|
+
size: (o) => o.size ?? 0,
|
|
358
|
+
kind: (o) => String(kindOf(o)),
|
|
359
|
+
}[sortBy] ?? nameOf
|
|
360
|
+
const dir = sortDir === 'desc' ? -1 : 1
|
|
361
|
+
return [...objects].sort((m, n) => {
|
|
362
|
+
const x = val(m), y = val(n)
|
|
363
|
+
if (x === y) return nameOf(m) < nameOf(n) ? -1 : 1
|
|
364
|
+
return (x < y ? -1 : 1) * dir
|
|
365
|
+
})
|
|
366
|
+
}
|
|
367
|
+
|
|
331
368
|
export function MediaLibraryBrowse({
|
|
332
369
|
client, title = 'MEDIA', bucket, onBucketChange, prefix: prefixProp, onPrefix, defaults, settings: settingsProp, onSettingsChange,
|
|
333
370
|
folderTree, headerActions, refreshKey, onOpen, autoFocus = false, settingsFooter, className = '',
|
|
@@ -342,6 +379,10 @@ export function MediaLibraryBrowse({
|
|
|
342
379
|
* real opinion about it (it owns `height`, the widths and `partition`, so
|
|
343
380
|
* those are deliberately absent from this list). */
|
|
344
381
|
thumbnailFor, folderMeta, formatDate, stackView,
|
|
382
|
+
/* THE TAB PILL'S LIST (ColumnBrowserMobileViews item 16). What a tab MEANS is
|
|
383
|
+
* the consumer's — a repo whose surfaces are routes wires its router here.
|
|
384
|
+
* No tabs, no pill, and the page is exactly what it was. */
|
|
385
|
+
tabs, activeTab, onTabChange,
|
|
345
386
|
}) {
|
|
346
387
|
const [ownPrefix, setOwnPrefix] = useState('')
|
|
347
388
|
const prefix = prefixProp ?? ownPrefix
|
|
@@ -357,8 +398,25 @@ export function MediaLibraryBrowse({
|
|
|
357
398
|
const [quickLook, setQuickLook] = useState(null)
|
|
358
399
|
const columnsRef = useRef(null)
|
|
359
400
|
const { folderView = 'columns', flat } = settings
|
|
401
|
+
/* PINNED SEARCH + `···` (item 15), below `md` only. Item 7 of the previous
|
|
402
|
+
* ticket hid the desktop control cluster and put nothing in its place, so
|
|
403
|
+
* SORT became unreachable on a phone; both references carry exactly these two
|
|
404
|
+
* controls above the list. Search is page state, not a setting — a query is
|
|
405
|
+
* something you are doing, not something you have configured. */
|
|
406
|
+
const [query, setQuery] = useState('')
|
|
407
|
+
|
|
408
|
+
/* Search filters the KEY SPACE, so the tree still navigates: a path survives
|
|
409
|
+
* when a file under it matches and ColumnBrowser's own partition does the
|
|
410
|
+
* rest. One view, rather than a flat results list nobody asked for. Sort
|
|
411
|
+
* orders the files the way the wall already sorts its own. */
|
|
412
|
+
const q = query.trim().toLowerCase()
|
|
413
|
+
const searched = useMemo(() => (q ? objects.filter((o) => o.key.toLowerCase().includes(q)) : objects), [objects, q])
|
|
414
|
+
const sortedObjects = useMemo(
|
|
415
|
+
() => sortObjects(searched, settings.sortBy, settings.sortDir),
|
|
416
|
+
[searched, settings.sortBy, settings.sortDir],
|
|
417
|
+
)
|
|
360
418
|
|
|
361
|
-
const scoped = prefix ?
|
|
419
|
+
const scoped = prefix ? sortedObjects.filter((o) => o.key.startsWith(prefix)) : sortedObjects
|
|
362
420
|
const { folders, files: dirFiles } = partition(scoped, prefix)
|
|
363
421
|
const keySet = new Set(objects.map((o) => o.key))
|
|
364
422
|
const levelFiles = flat ? scoped.map((o) => ({ ...o, displayKey: prefix ? o.key.slice(prefix.length) : o.key })) : dirFiles
|
|
@@ -474,6 +532,66 @@ export function MediaLibraryBrowse({
|
|
|
474
532
|
</div>
|
|
475
533
|
</div>
|
|
476
534
|
|
|
535
|
+
{/* PINNED ABOVE THE LIST, below `md` (item 15). Search sits here and not
|
|
536
|
+
inside the ContentFilters wall, because the wall is a DIFFERENT
|
|
537
|
+
SURFACE — on a phone it is a tab away, so a control living in it is
|
|
538
|
+
a control you cannot reach from the thing you are searching. */}
|
|
539
|
+
<div className="flex md:hidden items-center gap-2">
|
|
540
|
+
<SearchInput
|
|
541
|
+
value={query}
|
|
542
|
+
onChange={(e) => setQuery(e.target.value ?? '')}
|
|
543
|
+
onClear={() => setQuery('')}
|
|
544
|
+
placeholder="Search this bucket"
|
|
545
|
+
size="sm"
|
|
546
|
+
className="flex-1 min-w-0"
|
|
547
|
+
/>
|
|
548
|
+
{/* `···` — view mode and sort, the two the desktop cluster carries and
|
|
549
|
+
item 7 left with nowhere to go. `caret={false}`: an icon trigger is
|
|
550
|
+
already complete, and neither reference draws a chevron on it. */}
|
|
551
|
+
<MenuItem
|
|
552
|
+
label={<Icon name="more" size={16} />}
|
|
553
|
+
caret={false}
|
|
554
|
+
align="end"
|
|
555
|
+
buttonClassName="shrink-0 px-2"
|
|
556
|
+
>
|
|
557
|
+
{({ close }) => (
|
|
558
|
+
<div className="py-1 w-[200px]">
|
|
559
|
+
{STACK_VIEW_OPTIONS.map((opt) => (
|
|
560
|
+
<MenuDropdownItem
|
|
561
|
+
key={opt.value}
|
|
562
|
+
onClick={() => { setSettings({ ...settings, stackView: opt.value }); close() }}
|
|
563
|
+
shortcut={(settings.stackView ?? 'list') === opt.value ? <Icon name="check" size={11} /> : undefined}
|
|
564
|
+
>
|
|
565
|
+
{opt.label}
|
|
566
|
+
</MenuDropdownItem>
|
|
567
|
+
))}
|
|
568
|
+
<MenuDropdownDivider />
|
|
569
|
+
{SORT_OPTIONS.map((opt) => (
|
|
570
|
+
<MenuDropdownItem
|
|
571
|
+
key={opt.value}
|
|
572
|
+
/* tapping the ACTIVE key flips the direction, which is how
|
|
573
|
+
both references let you reverse without a second control */
|
|
574
|
+
onClick={() => {
|
|
575
|
+
const same = (settings.sortBy ?? 'name') === opt.value
|
|
576
|
+
setSettings({
|
|
577
|
+
...settings,
|
|
578
|
+
sortBy: opt.value,
|
|
579
|
+
sortDir: same && settings.sortDir !== 'desc' ? 'desc' : 'asc',
|
|
580
|
+
})
|
|
581
|
+
close()
|
|
582
|
+
}}
|
|
583
|
+
shortcut={(settings.sortBy ?? 'name') === opt.value
|
|
584
|
+
? <Icon name={settings.sortDir === 'desc' ? 'arrow-up' : 'arrow-down'} size={11} />
|
|
585
|
+
: undefined}
|
|
586
|
+
>
|
|
587
|
+
{opt.label}
|
|
588
|
+
</MenuDropdownItem>
|
|
589
|
+
))}
|
|
590
|
+
</div>
|
|
591
|
+
)}
|
|
592
|
+
</MenuItem>
|
|
593
|
+
</div>
|
|
594
|
+
|
|
477
595
|
{quickLook && (
|
|
478
596
|
<MediaInspector files={quickLook.files} index={quickLook.index} onClose={() => setQuickLook(null)} mediaUrl={mediaUrl} downloadUrl={downloadUrl} keySet={keySet}
|
|
479
597
|
onPrev={() => setQuickLook((q) => ({ ...q, index: (q.index - 1 + q.files.length) % q.files.length }))}
|
|
@@ -492,7 +610,7 @@ export function MediaLibraryBrowse({
|
|
|
492
610
|
onHeightChange={(px) => setSettings({ ...settings, columnHeight: px })}
|
|
493
611
|
columnWidths={settings.columnWidths}
|
|
494
612
|
onColumnResize={(i, px) => setSettings({ ...settings, columnWidths: { ...(settings.columnWidths ?? {}), [i]: px } })}
|
|
495
|
-
objects={
|
|
613
|
+
objects={sortedObjects.filter((o) => !isSystemFile(o.key)).map((o) => ({ ...o, key: `${VROOT}${o.key}` }))}
|
|
496
614
|
prefix={single ? prefix : (appRoot ? `${ROOT}/` : `${VROOT}${prefix}`)}
|
|
497
615
|
onPrefix={(v) => {
|
|
498
616
|
/* single: the browser's path IS the bucket path, no segment to strip */
|
|
@@ -514,7 +632,9 @@ export function MediaLibraryBrowse({
|
|
|
514
632
|
formatDate={formatDate}
|
|
515
633
|
thumbnailFor={thumbnailFor}
|
|
516
634
|
folderMeta={folderMeta}
|
|
517
|
-
|
|
635
|
+
/* the PROP wins when a consumer passes one; otherwise the `···`
|
|
636
|
+
owns it through settings (item 15) */
|
|
637
|
+
stackView={stackView ?? settings.stackView ?? 'list'}
|
|
518
638
|
renderPreview={(o) => {
|
|
519
639
|
const real = { ...o, key: o.key.slice(VROOT.length) }
|
|
520
640
|
if (isImage(real.contentType)) return <ImageFrame src={mediaUrl(real.key)} />
|
|
@@ -549,6 +669,24 @@ export function MediaLibraryBrowse({
|
|
|
549
669
|
</p>
|
|
550
670
|
)}
|
|
551
671
|
{void onOpen}
|
|
672
|
+
|
|
673
|
+
{/* THE TAB PILL (item 16) — floats over the list, so the list owes it
|
|
674
|
+
room or its last row sits under the bar forever.
|
|
675
|
+
THE SPACER GOES LAST, AND THAT IS THE WHOLE POINT
|
|
676
|
+
(TabBarSpacerAboveTheList, kol-r2b2 2026-09-04). `MobileTabBar` is
|
|
677
|
+
`fixed`, so where it sits in the tree is irrelevant — the SPACER is
|
|
678
|
+
in NORMAL FLOW, so its position is everything. Rendered before the
|
|
679
|
+
list it failed twice at once: a 56px hole punched into the gap under
|
|
680
|
+
the pinned search, and the last row still running 99px under the
|
|
681
|
+
bar. The comment above named the failure it was meant to prevent and
|
|
682
|
+
the block sat in the wrong place anyway. Below `md` only; above it
|
|
683
|
+
the 2026-08-26 one-view ruling stands. */}
|
|
684
|
+
{tabs?.length > 0 && (
|
|
685
|
+
<>
|
|
686
|
+
<div className="md:hidden" aria-hidden="true" style={{ height: TABBAR_H }} />
|
|
687
|
+
<MobileTabBar tabs={tabs} value={activeTab} onChange={onTabChange} />
|
|
688
|
+
</>
|
|
689
|
+
)}
|
|
552
690
|
</div>
|
|
553
691
|
</div>
|
|
554
692
|
)
|
|
@@ -558,6 +696,9 @@ export function MediaLibraryBrowse({
|
|
|
558
696
|
export function MediaLibraryLibrary({
|
|
559
697
|
client, title = 'MEDIA', bucket, onBucketChange, prefix = '', defaults, settings: settingsProp, onSettingsChange,
|
|
560
698
|
headerActions, refreshKey, header = true, stats = true, settingsFooter, className = '',
|
|
699
|
+
/* the same pill the browse page takes — the wall is one of the surfaces it
|
|
700
|
+
switches between, so it has to carry it too (item 16) */
|
|
701
|
+
tabs, activeTab, onTabChange,
|
|
561
702
|
}) {
|
|
562
703
|
const [ownBucket, setOwnBucket] = useState(bucket)
|
|
563
704
|
const bucketId = bucket ?? ownBucket
|
|
@@ -798,6 +939,15 @@ export function MediaLibraryLibrary({
|
|
|
798
939
|
)
|
|
799
940
|
}}
|
|
800
941
|
/>
|
|
942
|
+
|
|
943
|
+
{/* the pill again — the wall is one of the surfaces it switches, so it
|
|
944
|
+
carries the same bar and owes it the same room (item 16) */}
|
|
945
|
+
{tabs?.length > 0 && (
|
|
946
|
+
<>
|
|
947
|
+
<div className="md:hidden" aria-hidden="true" style={{ height: TABBAR_H }} />
|
|
948
|
+
<MobileTabBar tabs={tabs} value={activeTab} onChange={onTabChange} />
|
|
949
|
+
</>
|
|
950
|
+
)}
|
|
801
951
|
</div>
|
|
802
952
|
)
|
|
803
953
|
}
|
|
@@ -18,7 +18,26 @@ import CloseButton from './CloseButton.jsx'
|
|
|
18
18
|
const FOCUSABLE =
|
|
19
19
|
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
|
20
20
|
|
|
21
|
-
export default function FullscreenOverlay({
|
|
21
|
+
export default function FullscreenOverlay({
|
|
22
|
+
open, onClose, closeButton = true,
|
|
23
|
+
/* WHERE FOCUS LANDS ON OPEN. The sheet takes it by default, which is right
|
|
24
|
+
* for a browser and wrong for a sheet opened to be TYPED IN: a child's
|
|
25
|
+
* `autoFocus` cannot win, because child effects run BEFORE the parent's and
|
|
26
|
+
* this one moves focus afterwards — so the field focuses and is immediately
|
|
27
|
+
* robbed, with nothing in either file looking wrong (kol-fxr measured it on
|
|
28
|
+
* design-editor 0.10.0: Save As routed into the dialog correctly and focus
|
|
29
|
+
* sat on `.kol-overlay-sheet`). Pass a ref to the node that should hold it.
|
|
30
|
+
* A ref that is empty on mount falls back to the sheet, so a conditional
|
|
31
|
+
* field cannot leave the overlay unfocused.
|
|
32
|
+
*
|
|
33
|
+
* The ref may point at the control ITSELF or at a WRAPPER around it — the
|
|
34
|
+
* first focusable descendant is taken. That is deliberate: a DS input is a
|
|
35
|
+
* component, not a DOM node, and whether it forwards a ref is a detail no
|
|
36
|
+
* caller should have to know to put focus in it. A plain `<div ref>` always
|
|
37
|
+
* works. */
|
|
38
|
+
initialFocus,
|
|
39
|
+
children,
|
|
40
|
+
}) {
|
|
22
41
|
const sheetRef = useRef(null)
|
|
23
42
|
|
|
24
43
|
/* Escape closes; Tab is TRAPPED in the sheet (SettingsPanel, 2026-08-26 —
|
|
@@ -45,13 +64,19 @@ export default function FullscreenOverlay({ open, onClose, closeButton = true, c
|
|
|
45
64
|
const prev = document.body.style.overflow
|
|
46
65
|
document.body.style.overflow = 'hidden'
|
|
47
66
|
const prevFocus = document.activeElement
|
|
48
|
-
|
|
67
|
+
const wanted = initialFocus?.current
|
|
68
|
+
const target = wanted
|
|
69
|
+
? (typeof wanted.focus === 'function' && wanted.matches?.(FOCUSABLE)
|
|
70
|
+
? wanted
|
|
71
|
+
: wanted.querySelector?.(FOCUSABLE) ?? wanted)
|
|
72
|
+
: sheetRef.current
|
|
73
|
+
target?.focus?.()
|
|
49
74
|
return () => {
|
|
50
75
|
document.removeEventListener('keydown', onKey)
|
|
51
76
|
document.body.style.overflow = prev
|
|
52
77
|
if (prevFocus instanceof HTMLElement) prevFocus.focus()
|
|
53
78
|
}
|
|
54
|
-
}, [open, onClose])
|
|
79
|
+
}, [open, onClose, initialFocus])
|
|
55
80
|
|
|
56
81
|
if (!open) return null
|
|
57
82
|
|