@kolkrabbi/kol-component 0.1.2 → 0.3.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 +12 -4
- package/src/atoms/AnimatedTitle.jsx +108 -0
- package/src/atoms/AssetGrid.jsx +29 -0
- package/src/{primitives → atoms}/AssetPlaceholder.jsx +4 -2
- package/src/atoms/Avatar.jsx +4 -4
- package/src/atoms/Button.jsx +23 -9
- package/src/atoms/CopyButton.jsx +50 -0
- package/src/atoms/CurveOverlay.jsx +180 -0
- package/src/atoms/DocsToc.jsx +48 -0
- package/src/{molecules → atoms}/DropdownTagFilter.jsx +2 -1
- package/src/atoms/EmptyState.jsx +22 -0
- package/src/atoms/Figure.jsx +27 -0
- package/src/atoms/HlsVideo.jsx +56 -0
- package/src/atoms/Input.jsx +7 -2
- package/src/atoms/OverlayGlassPanel.jsx +40 -0
- package/src/atoms/PriceDisplay.jsx +34 -0
- package/src/atoms/ProsePreview.jsx +53 -0
- package/src/atoms/RotaryDial.jsx +150 -0
- package/src/atoms/SearchInput.jsx +108 -0
- package/src/{molecules → atoms}/SegmentedToggle.jsx +33 -14
- package/src/atoms/TextPressure.jsx +331 -0
- package/src/atoms/TiltCard.jsx +127 -0
- package/src/atoms/ToggleCheckbox.jsx +1 -1
- package/src/atoms/ToggleSwitch.jsx +1 -1
- package/src/atoms/TypeSample.jsx +50 -0
- package/src/atoms/TypeSpecCard.jsx +42 -0
- package/src/{molecules → atoms}/ViewToggle.jsx +6 -3
- package/src/graphics/Graphic.jsx +56 -18
- package/src/graphics/graphicData.js +19 -0
- package/src/hooks/cssVar.js +53 -0
- package/src/hooks/useAxisAnimation.js +91 -0
- package/src/hooks/usePrefersReducedMotion.js +21 -0
- package/src/hooks/useTilt.js +49 -0
- package/src/index.js +119 -39
- package/src/{primitives → molecules}/Accordion.jsx +4 -1
- package/src/molecules/AlignmentGrid.jsx +53 -0
- package/src/molecules/ArticleCard.jsx +178 -0
- package/src/molecules/CardFeatureItem.jsx +130 -0
- package/src/molecules/CodeBlock.jsx +11 -0
- package/src/molecules/ColorInputRow.jsx +179 -0
- package/src/molecules/ColorRamp.jsx +114 -0
- package/src/{atoms → molecules}/ColorSwatch.jsx +1 -1
- package/src/molecules/Dropdown.jsx +3 -2
- package/src/molecules/FramedMediaBand.jsx +56 -0
- package/src/{primitives → molecules}/Image.jsx +1 -1
- package/src/molecules/ImageBlock.jsx +34 -0
- package/src/molecules/MediaCard.jsx +97 -0
- package/src/molecules/MediaRow.jsx +49 -0
- package/src/molecules/MenuItem.jsx +3 -2
- package/src/molecules/MenuPopover.jsx +11 -96
- package/src/molecules/SelectionOverlay.jsx +108 -0
- package/src/molecules/ShapeDropdown.jsx +92 -0
- package/src/molecules/ShellDrawer.jsx +169 -0
- package/src/molecules/ShellSearchOverlay.jsx +177 -0
- package/src/{atoms → molecules}/Slider.jsx +1 -1
- package/src/molecules/SpecList.jsx +30 -0
- package/src/molecules/SpectrumControls.jsx +504 -0
- package/src/molecules/SwatchControls.jsx +217 -0
- package/src/molecules/TabsRow.jsx +87 -0
- package/src/molecules/VideoBlock.jsx +86 -0
- package/src/molecules/WorkListItem.jsx +83 -0
- package/src/molecules/foundry/SpecimenSectionHeader.jsx +89 -0
- package/src/organisms/ArticleHeader.jsx +95 -0
- package/src/organisms/AsciiCursor.jsx +526 -0
- package/src/organisms/BentoCard.jsx +187 -0
- package/src/organisms/Canvas.jsx +299 -0
- package/src/organisms/ColorLoader.jsx +155 -0
- package/src/{molecules → organisms}/ContentFilters.jsx +2 -2
- package/src/organisms/CtaGlobal.jsx +67 -0
- package/src/organisms/DiagonalMarqueeRiver.jsx +138 -0
- package/src/organisms/EditorShell.jsx +111 -0
- package/src/organisms/ErrorBoundary.jsx +70 -0
- package/src/organisms/FeatureSplit.jsx +82 -0
- package/src/organisms/FeaturedCarousel.jsx +258 -0
- package/src/organisms/FeaturesCardSection.jsx +90 -0
- package/src/organisms/FullBleedHero.jsx +111 -0
- package/src/organisms/GalleryCarousel.jsx +83 -0
- package/src/organisms/LoaderOverlay.jsx +30 -0
- package/src/organisms/MediaViewer.jsx +95 -0
- package/src/organisms/NewsletterBand.jsx +122 -0
- package/src/organisms/ParallaxShelf.jsx +141 -0
- package/src/organisms/PortableTextRenderer.jsx +115 -0
- package/src/organisms/ProductDetailLayout.jsx +189 -0
- package/src/organisms/ScrollDriftGallery.jsx +214 -0
- package/src/organisms/SpectrumGrid.jsx +90 -0
- package/src/organisms/StackHero.jsx +83 -0
- package/src/organisms/WorkCard.jsx +120 -0
- package/src/organisms/WorkViewToggle.jsx +170 -0
- package/src/organisms/foundry/FontPreviewSection.jsx +187 -0
- package/src/organisms/foundry/FoundryCharacterSets.jsx +113 -0
- package/src/organisms/foundry/GlyphMetricsGrid.jsx +335 -0
- package/src/organisms/foundry/TypefaceHero.jsx +107 -0
- package/src/organisms/foundry/TypefaceStyleSection.jsx +163 -0
- package/src/organisms/foundry/VariableFontSection.jsx +158 -0
- package/src/organisms/foundry/glyphData.js +30 -0
- package/src/organisms/foundry/index.js +21 -0
- package/src/primitives/CodeBlock.jsx +0 -41
- /package/src/{molecules → atoms}/Badge.jsx +0 -0
- /package/src/{primitives → atoms}/ExitPreview.jsx +0 -0
- /package/src/{primitives → atoms}/FullscreenOverlay.jsx +0 -0
- /package/src/{molecules → atoms}/LabeledControl.jsx +0 -0
- /package/src/{molecules → atoms}/Pill.jsx +0 -0
- /package/src/{molecules → atoms}/Popover.jsx +0 -0
- /package/src/{molecules → atoms}/QuantityInput.jsx +0 -0
- /package/src/{molecules → atoms}/QuantityStepper.jsx +0 -0
- /package/src/{molecules → atoms}/Section.jsx +0 -0
- /package/src/{molecules → atoms}/SectionLabel.jsx +0 -0
- /package/src/{molecules → atoms}/Tag.jsx +0 -0
- /package/src/{molecules → atoms}/ToggleBracket.jsx +0 -0
- /package/src/{primitives → organisms}/Carousel.jsx +0 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import Image from './Image'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* FramedMediaBand — full-width media breather band: a centered, aspect-locked
|
|
5
|
+
* frame (bordered surface-secondary panel, rounded) holding one object-cover
|
|
6
|
+
* image. Extracted from the foundry TypefacePage, where it sat inlined 5x
|
|
7
|
+
* verbatim between content sections. The double radius (`rounded` panel,
|
|
8
|
+
* `rounded-[4px]` image) is intentional — the image nests inside the border.
|
|
9
|
+
*
|
|
10
|
+
* Presentational — the caller passes finished `src`/`srcSet` strings; any
|
|
11
|
+
* CDN size-ladder generation stays at the call site. `media` swaps an
|
|
12
|
+
* arbitrary node into the frame in place of the Image (video, canvas); it
|
|
13
|
+
* should self-size with `w-full h-full`.
|
|
14
|
+
*
|
|
15
|
+
* @param {string} src image src (a finished URL, no ladder logic here)
|
|
16
|
+
* @param {string} srcSet responsive srcSet, precomputed by the caller
|
|
17
|
+
* @param {string} sizes img sizes attr
|
|
18
|
+
* @param {string} alt alt text, authored at the call site
|
|
19
|
+
* @param {ReactNode} media replaces the Image inside the frame
|
|
20
|
+
* @param {ReactNode} caption optional caption line under the frame
|
|
21
|
+
* @param {string} aspectRatio frame aspect ratio, CSS value (e.g. '2/1')
|
|
22
|
+
* @param {string} maxWidth frame max-width class
|
|
23
|
+
* @param {string} className section-level extras (e.g. 'mt-12')
|
|
24
|
+
*/
|
|
25
|
+
export default function FramedMediaBand({
|
|
26
|
+
src,
|
|
27
|
+
srcSet,
|
|
28
|
+
sizes = '(max-width: 1400px) 100vw, 1400px',
|
|
29
|
+
alt = '',
|
|
30
|
+
media,
|
|
31
|
+
caption,
|
|
32
|
+
aspectRatio = '2/1',
|
|
33
|
+
maxWidth = 'max-w-[1400px]',
|
|
34
|
+
className = '',
|
|
35
|
+
}) {
|
|
36
|
+
return (
|
|
37
|
+
<section className={`w-full overflow-hidden py-16 ${className}`.trim()}>
|
|
38
|
+
<div className={`${maxWidth} mx-auto`}>
|
|
39
|
+
<div style={{ aspectRatio }}>
|
|
40
|
+
<div className="w-full h-full bg-surface-secondary rounded border border-fg-08">
|
|
41
|
+
{media ?? (
|
|
42
|
+
<Image
|
|
43
|
+
src={src}
|
|
44
|
+
srcSet={srcSet}
|
|
45
|
+
sizes={sizes}
|
|
46
|
+
alt={alt}
|
|
47
|
+
className="w-full h-full object-cover rounded-[4px]"
|
|
48
|
+
/>
|
|
49
|
+
)}
|
|
50
|
+
</div>
|
|
51
|
+
</div>
|
|
52
|
+
{caption && <p className="kol-mono-12 text-fg-48 mt-3">{caption}</p>}
|
|
53
|
+
</div>
|
|
54
|
+
</section>
|
|
55
|
+
)
|
|
56
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import Figure from '../atoms/Figure.jsx'
|
|
2
|
+
import Image from './Image.jsx'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* ImageBlock — a captioned prose image: the DS Figure shell (optional label,
|
|
6
|
+
* aspect-locked bordered frame, optional figcaption) wrapping a cover-fit DS
|
|
7
|
+
* Image. The long-form counterpart to VideoBlock — both compose the same
|
|
8
|
+
* Figure atom, so the frame chrome lives in one place.
|
|
9
|
+
*
|
|
10
|
+
* De-Sanitized: takes a resolved `src` string (no CMS `value` object, no
|
|
11
|
+
* SanityImage URL builder). Missing `src` degrades to Image's own
|
|
12
|
+
* AssetPlaceholder rather than rendering an empty frame.
|
|
13
|
+
*
|
|
14
|
+
* Label and caption render exactly as authored — no casing transforms.
|
|
15
|
+
*
|
|
16
|
+
* @param {string} src resolved image URL
|
|
17
|
+
* @param {string} alt alt text
|
|
18
|
+
* @param {string} label small mono label above the frame
|
|
19
|
+
* @param {string} caption figcaption below the frame
|
|
20
|
+
* @param {string} aspect CSS aspect-ratio for the frame (default '5/3')
|
|
21
|
+
* @param {string} className extra classes on the <figure>
|
|
22
|
+
*/
|
|
23
|
+
export default function ImageBlock({ src, alt = '', label, caption, aspect = '5/3', className = '' }) {
|
|
24
|
+
return (
|
|
25
|
+
<Figure label={label} caption={caption} aspect={aspect} className={className}>
|
|
26
|
+
<Image
|
|
27
|
+
src={src}
|
|
28
|
+
alt={alt}
|
|
29
|
+
className="object-cover"
|
|
30
|
+
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
|
31
|
+
/>
|
|
32
|
+
</Figure>
|
|
33
|
+
)
|
|
34
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { Icon } from '@kolkrabbi/kol-loader'
|
|
2
|
+
|
|
3
|
+
/* taxonomy-ok: nests kol-loader's Icon (a package import the relative-import
|
|
4
|
+
* check can't see) plus the same-file SelectIndicator. */
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* SelectIndicator — passive square check indicator for multi-select rows and
|
|
8
|
+
* cards. `on` = checked. Deliberately NOT ToggleCheckbox: that is a labeled
|
|
9
|
+
* form control with a real <input>, which double-fires inside a click-target
|
|
10
|
+
* card and misleads assistive tech — here the CARD is the toggle, this is
|
|
11
|
+
* only its visual state. Inline CSS vars because `bg-fg-default` isn't a
|
|
12
|
+
* generated Tailwind utility; the checked fill reads from
|
|
13
|
+
* `--kol-surface-on-primary` (solid fg, theme-correct).
|
|
14
|
+
*
|
|
15
|
+
* Shared by MediaCard and MediaRow; not exported from the package barrel.
|
|
16
|
+
*/
|
|
17
|
+
export function SelectIndicator({ on = false }) {
|
|
18
|
+
return (
|
|
19
|
+
<span
|
|
20
|
+
className="w-4 h-4 shrink-0 rounded-sm border flex items-center justify-center transition-colors"
|
|
21
|
+
style={
|
|
22
|
+
on
|
|
23
|
+
? { background: 'var(--kol-surface-on-primary)', borderColor: 'var(--kol-surface-on-primary)' }
|
|
24
|
+
: { background: 'var(--kol-fg-absolute-16, rgba(0,0,0,0.15))', borderColor: 'var(--kol-fg-absolute-48, rgba(0,0,0,0.4))' }
|
|
25
|
+
}
|
|
26
|
+
aria-hidden="true"
|
|
27
|
+
>
|
|
28
|
+
{on && <Icon name="check" size={11} style={{ color: 'var(--kol-surface-primary)' }} />}
|
|
29
|
+
</span>
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* MediaCard — grid tile for one media object: square thumbnail with a
|
|
35
|
+
* top-right download overlay (or a top-left select checkbox in select mode),
|
|
36
|
+
* then name / meta / actions stacked below. The grid-view counterpart to
|
|
37
|
+
* MediaRow (same slot contract).
|
|
38
|
+
*
|
|
39
|
+
* Presentational — the parent supplies rendered slots; no media loading,
|
|
40
|
+
* fetch, or rename logic lives here. Grid track sizing belongs to the parent
|
|
41
|
+
* list; the card just fills its cell.
|
|
42
|
+
*
|
|
43
|
+
* @param {ReactNode} thumb square thumbnail (image/video/placeholder)
|
|
44
|
+
* @param {ReactNode} name name cell (plain text or an inline editor)
|
|
45
|
+
* @param {string} meta one-line secondary text (e.g. "1.2 MB · 2026-06-19")
|
|
46
|
+
* @param {ReactNode} actions row of action buttons (hidden in select mode)
|
|
47
|
+
* @param {string} downloadHref href for the overlay download button (omit to hide)
|
|
48
|
+
* @param {boolean} selectMode selection mode on → checkbox replaces download, whole card toggles
|
|
49
|
+
* @param {boolean} selected this card is selected (stronger border + checked box)
|
|
50
|
+
* @param {Function} onSelect (event) => void — card click in select mode; gets shiftKey for range
|
|
51
|
+
*/
|
|
52
|
+
export default function MediaCard({
|
|
53
|
+
thumb,
|
|
54
|
+
name,
|
|
55
|
+
meta,
|
|
56
|
+
actions,
|
|
57
|
+
downloadHref,
|
|
58
|
+
selectMode = false,
|
|
59
|
+
selected = false,
|
|
60
|
+
onSelect,
|
|
61
|
+
}) {
|
|
62
|
+
return (
|
|
63
|
+
<li
|
|
64
|
+
onClick={selectMode ? onSelect : undefined}
|
|
65
|
+
className={`flex flex-col rounded overflow-hidden border bg-fg-02 ${selectMode ? 'cursor-pointer select-none' : ''}`}
|
|
66
|
+
style={{ borderColor: selected ? 'var(--kol-fg-64)' : 'var(--kol-fg-12)' }}
|
|
67
|
+
>
|
|
68
|
+
<div className="aspect-square relative">
|
|
69
|
+
{thumb}
|
|
70
|
+
{selectMode ? (
|
|
71
|
+
<span
|
|
72
|
+
className="absolute top-3 left-3 rounded p-1"
|
|
73
|
+
style={{ background: 'var(--kol-fg-absolute-12, rgba(0,0,0,0.4))', backdropFilter: 'blur(4px)' }}
|
|
74
|
+
>
|
|
75
|
+
<SelectIndicator on={selected} />
|
|
76
|
+
</span>
|
|
77
|
+
) : downloadHref ? (
|
|
78
|
+
<a
|
|
79
|
+
href={downloadHref}
|
|
80
|
+
aria-label="Download"
|
|
81
|
+
title="Download"
|
|
82
|
+
className="absolute top-3 right-3 inline-flex items-center justify-center w-8 h-8 rounded text-emphasis hover:bg-fg-absolute-24 transition-colors"
|
|
83
|
+
style={{ background: 'var(--kol-fg-absolute-12, rgba(0,0,0,0.4))', backdropFilter: 'blur(4px)' }}
|
|
84
|
+
onClick={(e) => e.stopPropagation()}
|
|
85
|
+
>
|
|
86
|
+
<Icon name="download" size={16} />
|
|
87
|
+
</a>
|
|
88
|
+
) : null}
|
|
89
|
+
</div>
|
|
90
|
+
<div className="p-3 flex flex-col gap-2">
|
|
91
|
+
{name}
|
|
92
|
+
<p className="kol-mono-12 text-fg-48">{meta}</p>
|
|
93
|
+
{!selectMode && actions}
|
|
94
|
+
</div>
|
|
95
|
+
</li>
|
|
96
|
+
)
|
|
97
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { SelectIndicator } from './MediaCard.jsx'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* MediaRow — list row for one media object: optional select checkbox, small
|
|
5
|
+
* thumbnail, name (flex), fixed-width date + size columns, then actions.
|
|
6
|
+
* The list-view counterpart to MediaCard (same slot contract).
|
|
7
|
+
*
|
|
8
|
+
* Presentational — the parent supplies rendered slots; interaction outside
|
|
9
|
+
* select mode lives in the thumb / name / actions slots. Column widths are
|
|
10
|
+
* consumer-tunable via `dateWidth` / `sizeWidth`.
|
|
11
|
+
*
|
|
12
|
+
* @param {ReactNode} thumb small thumbnail (48px square)
|
|
13
|
+
* @param {ReactNode} name name cell (plain text or an inline editor)
|
|
14
|
+
* @param {string} date right-aligned date column
|
|
15
|
+
* @param {string} size right-aligned size column
|
|
16
|
+
* @param {ReactNode} actions row of action buttons (hidden in select mode)
|
|
17
|
+
* @param {string} dateWidth Tailwind width class for the date column
|
|
18
|
+
* @param {string} sizeWidth Tailwind width class for the size column
|
|
19
|
+
* @param {boolean} selectMode selection mode on → checkbox shown, whole row toggles, actions hidden
|
|
20
|
+
* @param {boolean} selected this row is selected (highlight + checked box)
|
|
21
|
+
* @param {Function} onSelect (event) => void — row click in select mode; gets shiftKey for range
|
|
22
|
+
*/
|
|
23
|
+
export default function MediaRow({
|
|
24
|
+
thumb,
|
|
25
|
+
name,
|
|
26
|
+
date,
|
|
27
|
+
size,
|
|
28
|
+
actions,
|
|
29
|
+
dateWidth = 'w-24',
|
|
30
|
+
sizeWidth = 'w-20',
|
|
31
|
+
selectMode = false,
|
|
32
|
+
selected = false,
|
|
33
|
+
onSelect,
|
|
34
|
+
}) {
|
|
35
|
+
return (
|
|
36
|
+
<li
|
|
37
|
+
onClick={selectMode ? onSelect : undefined}
|
|
38
|
+
className={`flex items-center gap-3 py-2 border-b ${selectMode ? 'cursor-pointer select-none' : ''} ${selected ? 'bg-fg-08' : ''}`}
|
|
39
|
+
style={{ borderColor: 'var(--kol-fg-08)' }}
|
|
40
|
+
>
|
|
41
|
+
{selectMode && <SelectIndicator on={selected} />}
|
|
42
|
+
<div className="w-12 h-12 shrink-0 rounded overflow-hidden">{thumb}</div>
|
|
43
|
+
<div className="flex-1 min-w-0">{name}</div>
|
|
44
|
+
<p className={`kol-mono-12 text-fg-32 shrink-0 text-right ${dateWidth}`}>{date}</p>
|
|
45
|
+
<p className={`kol-mono-12 text-fg-48 shrink-0 text-right ${sizeWidth}`}>{size}</p>
|
|
46
|
+
{!selectMode && <div className="shrink-0">{actions}</div>}
|
|
47
|
+
</li>
|
|
48
|
+
)
|
|
49
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useState } from 'react'
|
|
2
2
|
import { Icon } from '@kolkrabbi/kol-loader'
|
|
3
|
-
import { PopoverPanel, usePopover } from '
|
|
3
|
+
import { PopoverPanel, usePopover } from '../atoms/Popover.jsx'
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* MenuItem — top-level menu entry. Trigger button + popover panel.
|
|
@@ -26,8 +26,9 @@ export function MenuItem({
|
|
|
26
26
|
panelClassName = '',
|
|
27
27
|
panelStyle,
|
|
28
28
|
buttonClassName = '',
|
|
29
|
+
defaultOpen = false,
|
|
29
30
|
}) {
|
|
30
|
-
const [open, setOpen] = useState(
|
|
31
|
+
const [open, setOpen] = useState(defaultOpen)
|
|
31
32
|
const popover = usePopover({
|
|
32
33
|
open,
|
|
33
34
|
onOpenChange: setOpen,
|
|
@@ -1,103 +1,18 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { MenuItem as MenuTrigger } from './MenuItem.jsx'
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* MenuPopover —
|
|
4
|
+
* MenuPopover — DEPRECATED alias of MenuItem (2026-07-02 menu-family
|
|
5
|
+
* unification).
|
|
5
6
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* via getBoundingClientRect + position:fixed so it escapes overflow:auto
|
|
13
|
-
* clipping. Pass `panelClassName` to size the panel (e.g. wider for
|
|
14
|
-
* Templates).
|
|
15
|
-
*
|
|
16
|
-
* For value-list selection (single value, active state) use `Dropdown`
|
|
17
|
-
* instead — this primitive is for action menus / popover panels that
|
|
18
|
-
* hold arbitrary children.
|
|
7
|
+
* The two triggers had an identical API (label, children incl. ({ close })
|
|
8
|
+
* render-prop, align, panelClassName, panelStyle, buttonClassName,
|
|
9
|
+
* defaultOpen) and did the same job — MenuPopover with hand-rolled
|
|
10
|
+
* fixed positioning, MenuItem on floating-ui (portal, auto-flip, focus
|
|
11
|
+
* management). One implementation now: MenuItem. This alias keeps existing
|
|
12
|
+
* call-sites working; migrate imports to MenuItem. Removal in the next major.
|
|
19
13
|
*/
|
|
20
|
-
export function MenuPopover({
|
|
21
|
-
|
|
22
|
-
children,
|
|
23
|
-
align = 'start',
|
|
24
|
-
panelClassName = '',
|
|
25
|
-
panelStyle,
|
|
26
|
-
buttonClassName = '',
|
|
27
|
-
}) {
|
|
28
|
-
const [open, setOpen] = useState(false)
|
|
29
|
-
const wrapRef = useRef(null)
|
|
30
|
-
const buttonRef = useRef(null)
|
|
31
|
-
const [panelPos, setPanelPos] = useState(null)
|
|
32
|
-
|
|
33
|
-
useEffect(() => {
|
|
34
|
-
if (!open) return
|
|
35
|
-
const onDown = (e) => {
|
|
36
|
-
if (!wrapRef.current?.contains(e.target)) setOpen(false)
|
|
37
|
-
}
|
|
38
|
-
const onKey = (e) => { if (e.key === 'Escape') setOpen(false) }
|
|
39
|
-
document.addEventListener('mousedown', onDown)
|
|
40
|
-
document.addEventListener('keydown', onKey)
|
|
41
|
-
return () => {
|
|
42
|
-
document.removeEventListener('mousedown', onDown)
|
|
43
|
-
document.removeEventListener('keydown', onKey)
|
|
44
|
-
}
|
|
45
|
-
}, [open])
|
|
46
|
-
|
|
47
|
-
useEffect(() => {
|
|
48
|
-
if (!open) { setPanelPos(null); return }
|
|
49
|
-
const update = () => {
|
|
50
|
-
const rect = buttonRef.current?.getBoundingClientRect()
|
|
51
|
-
if (!rect) return
|
|
52
|
-
setPanelPos({ top: rect.bottom + 4, left: rect.left, right: rect.right })
|
|
53
|
-
}
|
|
54
|
-
update()
|
|
55
|
-
window.addEventListener('resize', update)
|
|
56
|
-
window.addEventListener('scroll', update, true)
|
|
57
|
-
return () => {
|
|
58
|
-
window.removeEventListener('resize', update)
|
|
59
|
-
window.removeEventListener('scroll', update, true)
|
|
60
|
-
}
|
|
61
|
-
}, [open])
|
|
62
|
-
|
|
63
|
-
const close = () => setOpen(false)
|
|
64
|
-
|
|
65
|
-
const positioned = panelPos && (
|
|
66
|
-
align === 'end'
|
|
67
|
-
? { top: panelPos.top, right: window.innerWidth - panelPos.right }
|
|
68
|
-
: { top: panelPos.top, left: panelPos.left }
|
|
69
|
-
)
|
|
70
|
-
|
|
71
|
-
return (
|
|
72
|
-
<div ref={wrapRef} className="relative inline-block">
|
|
73
|
-
<button
|
|
74
|
-
ref={buttonRef}
|
|
75
|
-
type="button"
|
|
76
|
-
onClick={() => setOpen((v) => !v)}
|
|
77
|
-
aria-haspopup="menu"
|
|
78
|
-
aria-expanded={open}
|
|
79
|
-
className={`kol-helper-12 px-3 h-8 inline-flex items-center gap-1 rounded text-meta hover:text-emphasis transition-colors ${buttonClassName}`}
|
|
80
|
-
>
|
|
81
|
-
<span>{label}</span>
|
|
82
|
-
<svg width="10" height="10" viewBox="0 0 12 12" aria-hidden="true">
|
|
83
|
-
<path d="m3 5 3 3 3-3" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
|
84
|
-
</svg>
|
|
85
|
-
</button>
|
|
86
|
-
{open && positioned && (
|
|
87
|
-
<div
|
|
88
|
-
role="menu"
|
|
89
|
-
className={`fixed z-[1000] bg-surface-primary border border-fg-08 rounded shadow-lg ${panelClassName}`}
|
|
90
|
-
style={{ ...positioned, ...panelStyle }}
|
|
91
|
-
onClick={(e) => {
|
|
92
|
-
/* close on item click — items inside fire their handler then bubble. */
|
|
93
|
-
if (e.target.closest('[data-menu-item]')) close()
|
|
94
|
-
}}
|
|
95
|
-
>
|
|
96
|
-
{typeof children === 'function' ? children({ close }) : children}
|
|
97
|
-
</div>
|
|
98
|
-
)}
|
|
99
|
-
</div>
|
|
100
|
-
)
|
|
14
|
+
export function MenuPopover(props) {
|
|
15
|
+
return <MenuTrigger {...props} />
|
|
101
16
|
}
|
|
102
17
|
|
|
103
18
|
/**
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/* taxonomy-ok: presentational transform-chrome overlay. It nests no KOL
|
|
2
|
+
* component (pure inline-styled squares + label), so by the letter of the
|
|
3
|
+
* molecule test it reads as an atom — but the lobby spec places it as a
|
|
4
|
+
* molecule: a reusable compound bounding-box/handles primitive that pairs with
|
|
5
|
+
* the Canvas scale layer, not a base atom. Kept here per that spec. */
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* SelectionOverlay — pure transform chrome for a selected box.
|
|
9
|
+
*
|
|
10
|
+
* Renders a dashed outline, 8 named resize handles, and a `W × H` dimension
|
|
11
|
+
* label, all positioned in the **same 1080-virtual coordinate space** the
|
|
12
|
+
* target lives in (pairs with Canvas's scale layer — place it as a sibling of
|
|
13
|
+
* the box inside the same scale layer). Each handle carries a
|
|
14
|
+
* `data-handle="NW|N|NE|E|SE|S|SW|W"` attribute so a parent's pointer router
|
|
15
|
+
* can start the right resize mode. No interaction logic of its own — the drag
|
|
16
|
+
* math lives in the consumer, which reads `e.target.dataset.handle`.
|
|
17
|
+
*
|
|
18
|
+
* Ported from the brand editor with the `layer` model reduced to a flat `box`
|
|
19
|
+
* (per lobby spec): renders nothing when there's no positional box.
|
|
20
|
+
*
|
|
21
|
+
* @param {{x:number,y:number,w:number,h:number}} box virtual-coord position + size; null/x==null → renders nothing
|
|
22
|
+
* @param {boolean} showHandles render the 8 resize handles (default true)
|
|
23
|
+
* @param {boolean} showLabel render the `W × H` dimension label (default true)
|
|
24
|
+
* @param {number} handleSize handle square size in virtual px (default 10)
|
|
25
|
+
* @param {string} accentColor outline + handle + label color (default var(--kol-accent-primary))
|
|
26
|
+
* @param {Function} labelFormatter (box) => string — dimension readout (default `${round(w)} × ${round(h)}`)
|
|
27
|
+
*/
|
|
28
|
+
const HANDLE_DIRS = [
|
|
29
|
+
{ dir: 'NW', cursor: 'nwse-resize', x: 0, y: 0 },
|
|
30
|
+
{ dir: 'N', cursor: 'ns-resize', x: 0.5, y: 0 },
|
|
31
|
+
{ dir: 'NE', cursor: 'nesw-resize', x: 1, y: 0 },
|
|
32
|
+
{ dir: 'E', cursor: 'ew-resize', x: 1, y: 0.5 },
|
|
33
|
+
{ dir: 'SE', cursor: 'nwse-resize', x: 1, y: 1 },
|
|
34
|
+
{ dir: 'S', cursor: 'ns-resize', x: 0.5, y: 1 },
|
|
35
|
+
{ dir: 'SW', cursor: 'nesw-resize', x: 0, y: 1 },
|
|
36
|
+
{ dir: 'W', cursor: 'ew-resize', x: 0, y: 0.5 },
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
export default function SelectionOverlay({
|
|
40
|
+
box,
|
|
41
|
+
showHandles = true,
|
|
42
|
+
showLabel = true,
|
|
43
|
+
handleSize = 10,
|
|
44
|
+
accentColor = 'var(--kol-accent-primary)',
|
|
45
|
+
labelFormatter = (b) => `${Math.round(b.w)} × ${Math.round(b.h)}`,
|
|
46
|
+
}) {
|
|
47
|
+
if (!box || box.x == null) return null /* no positional box → no chrome */
|
|
48
|
+
|
|
49
|
+
const { x, y, w, h } = box
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<div
|
|
53
|
+
style={{
|
|
54
|
+
position: 'absolute',
|
|
55
|
+
left: x, top: y,
|
|
56
|
+
width: w, height: h,
|
|
57
|
+
pointerEvents: 'none',
|
|
58
|
+
zIndex: 100,
|
|
59
|
+
}}
|
|
60
|
+
>
|
|
61
|
+
<div
|
|
62
|
+
style={{
|
|
63
|
+
position: 'absolute', inset: 0,
|
|
64
|
+
outline: `1px dashed ${accentColor}`,
|
|
65
|
+
outlineOffset: 0,
|
|
66
|
+
}}
|
|
67
|
+
/>
|
|
68
|
+
{showHandles && HANDLE_DIRS.map(({ dir, cursor, x: hx, y: hy }) => (
|
|
69
|
+
<div
|
|
70
|
+
key={dir}
|
|
71
|
+
data-handle={dir}
|
|
72
|
+
style={{
|
|
73
|
+
position: 'absolute',
|
|
74
|
+
left: `calc(${hx * 100}% - ${handleSize / 2}px)`,
|
|
75
|
+
top: `calc(${hy * 100}% - ${handleSize / 2}px)`,
|
|
76
|
+
width: handleSize,
|
|
77
|
+
height: handleSize,
|
|
78
|
+
background: 'white',
|
|
79
|
+
border: `1px solid ${accentColor}`,
|
|
80
|
+
cursor,
|
|
81
|
+
pointerEvents: 'auto',
|
|
82
|
+
}}
|
|
83
|
+
/>
|
|
84
|
+
))}
|
|
85
|
+
{showLabel && (
|
|
86
|
+
<span
|
|
87
|
+
style={{
|
|
88
|
+
position: 'absolute',
|
|
89
|
+
left: 0,
|
|
90
|
+
top: '100%',
|
|
91
|
+
marginTop: 6,
|
|
92
|
+
fontFamily: 'var(--kol-font-family-mono)',
|
|
93
|
+
fontSize: 10,
|
|
94
|
+
letterSpacing: '0.04em',
|
|
95
|
+
color: accentColor,
|
|
96
|
+
background: 'rgba(0,0,0,0.6)',
|
|
97
|
+
padding: '2px 6px',
|
|
98
|
+
borderRadius: 2,
|
|
99
|
+
whiteSpace: 'nowrap',
|
|
100
|
+
pointerEvents: 'none',
|
|
101
|
+
}}
|
|
102
|
+
>
|
|
103
|
+
{labelFormatter(box)}
|
|
104
|
+
</span>
|
|
105
|
+
)}
|
|
106
|
+
</div>
|
|
107
|
+
)
|
|
108
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import { Icon } from '@kolkrabbi/kol-loader'
|
|
3
|
+
import Button from '../atoms/Button.jsx'
|
|
4
|
+
import { PopoverPanel, usePopover } from '../atoms/Popover.jsx'
|
|
5
|
+
import { MenuDropdownItem } from './MenuItem.jsx'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* ShapeDropdown — split icon-button + variant-menu molecule (the tool-palette
|
|
9
|
+
* idiom: Select · Text · [Shape ▾] · Pattern). The main button reflects the
|
|
10
|
+
* current variant and fires `onAction` with its id; the chevron half opens a
|
|
11
|
+
* menu of all variants — picking one fires `onChange` and closes.
|
|
12
|
+
*
|
|
13
|
+
* Composed from Button (both trigger halves), usePopover/PopoverPanel (menu
|
|
14
|
+
* positioning, dismiss, portal) and MenuDropdownItem (rows — same rows as the
|
|
15
|
+
* Dropdown molecule, ✓ marks the active variant).
|
|
16
|
+
*
|
|
17
|
+
* For single-value list selection with a text trigger use `Dropdown`; this is
|
|
18
|
+
* for tool bars where the trigger is itself an action.
|
|
19
|
+
*
|
|
20
|
+
* @param {Object} props
|
|
21
|
+
* @param {{id: string, label: string, icon?: string}[]} props.options - Variants: menu rows + trigger glyph. `icon` optional — the trigger falls back to the label.
|
|
22
|
+
* @param {string} props.value - Active variant id (controlled)
|
|
23
|
+
* @param {Function} props.onChange - Fires with the picked variant id (menu selection)
|
|
24
|
+
* @param {Function} props.onAction - Fires with the current variant id (main-button click)
|
|
25
|
+
* @param {string} props.className - Additional classes on the wrapper
|
|
26
|
+
*/
|
|
27
|
+
const ShapeDropdown = ({ options = [], value, onChange, onAction, className = '' }) => {
|
|
28
|
+
const [open, setOpen] = useState(false)
|
|
29
|
+
const popover = usePopover({
|
|
30
|
+
open,
|
|
31
|
+
onOpenChange: setOpen,
|
|
32
|
+
placement: 'bottom-start',
|
|
33
|
+
offset: 4,
|
|
34
|
+
role: 'menu',
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
const current = options.find((option) => option.id === value) || options[0]
|
|
38
|
+
|
|
39
|
+
const handleSelect = (option) => {
|
|
40
|
+
onChange?.(option.id)
|
|
41
|
+
setOpen(false)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<div className={`inline-flex items-center ${className}`.trim()}>
|
|
46
|
+
{current?.icon ? (
|
|
47
|
+
<Button
|
|
48
|
+
variant="ghost"
|
|
49
|
+
size="sm"
|
|
50
|
+
quiet
|
|
51
|
+
iconOnly={current.icon}
|
|
52
|
+
aria-label={current.label}
|
|
53
|
+
title={current.label}
|
|
54
|
+
onClick={() => onAction?.(current.id)}
|
|
55
|
+
/>
|
|
56
|
+
) : (
|
|
57
|
+
<Button variant="ghost" size="sm" quiet onClick={() => onAction?.(current?.id)}>
|
|
58
|
+
{current?.label}
|
|
59
|
+
</Button>
|
|
60
|
+
)}
|
|
61
|
+
{/* Button doesn't forward refs — anchor the popover on a span wrapper,
|
|
62
|
+
* same pattern as Tooltip in atoms/Popover.jsx. Clicks on the inner
|
|
63
|
+
* button bubble to the span, where useClick toggles the menu. */}
|
|
64
|
+
<span
|
|
65
|
+
ref={popover.refs.setReference}
|
|
66
|
+
{...popover.getReferenceProps()}
|
|
67
|
+
className="inline-flex"
|
|
68
|
+
>
|
|
69
|
+
<Button variant="ghost" size="sm" quiet iconOnly="chevron-down" iconSize={10} aria-label="Variants" />
|
|
70
|
+
</span>
|
|
71
|
+
<PopoverPanel
|
|
72
|
+
popover={popover}
|
|
73
|
+
panel={false}
|
|
74
|
+
focus={false}
|
|
75
|
+
className="bg-surface-secondary border border-fg-08 rounded shadow-lg"
|
|
76
|
+
>
|
|
77
|
+
{options.map((option) => (
|
|
78
|
+
<MenuDropdownItem
|
|
79
|
+
key={option.id}
|
|
80
|
+
onClick={() => handleSelect(option)}
|
|
81
|
+
iconLeft={option.icon ? <Icon name={option.icon} size={14} /> : undefined}
|
|
82
|
+
shortcut={option.id === current?.id ? <Icon name="check" size={11} /> : undefined}
|
|
83
|
+
>
|
|
84
|
+
{option.label}
|
|
85
|
+
</MenuDropdownItem>
|
|
86
|
+
))}
|
|
87
|
+
</PopoverPanel>
|
|
88
|
+
</div>
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export default ShapeDropdown
|