@kolkrabbi/kol-component 0.1.2 → 0.2.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 +2 -2
- package/src/{primitives → atoms}/AssetPlaceholder.jsx +4 -2
- package/src/atoms/Avatar.jsx +4 -4
- package/src/atoms/Button.jsx +6 -3
- package/src/atoms/CopyButton.jsx +50 -0
- package/src/{molecules → atoms}/DropdownTagFilter.jsx +2 -1
- package/src/atoms/Input.jsx +7 -2
- package/src/{molecules → atoms}/SegmentedToggle.jsx +33 -14
- package/src/atoms/ToggleCheckbox.jsx +1 -1
- package/src/atoms/ToggleSwitch.jsx +1 -1
- package/src/{molecules → atoms}/ViewToggle.jsx +6 -3
- package/src/graphics/Graphic.jsx +56 -18
- package/src/graphics/graphicData.js +19 -0
- package/src/index.js +54 -39
- package/src/{primitives → molecules}/Accordion.jsx +4 -1
- package/src/molecules/CodeBlock.jsx +11 -0
- package/src/{atoms → molecules}/ColorSwatch.jsx +1 -1
- package/src/molecules/Dropdown.jsx +3 -2
- package/src/{primitives → molecules}/Image.jsx +1 -1
- 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/{atoms → molecules}/Slider.jsx +1 -1
- package/src/{molecules → organisms}/ContentFilters.jsx +2 -2
- 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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kolkrabbi/kol-component",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "KOL design-system components — atoms through organisms, emitting canonical kol-* classes. Pairs with @kolkrabbi/kol-theme for styling.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"dependencies": {
|
|
24
24
|
"@floating-ui/react": "^0.27.19",
|
|
25
25
|
"embla-carousel-react": "^8.6.0",
|
|
26
|
-
"@kolkrabbi/kol-loader": "0.
|
|
26
|
+
"@kolkrabbi/kol-loader": "0.3.0"
|
|
27
27
|
},
|
|
28
28
|
"peerDependencies": {
|
|
29
29
|
"react": "^18.3.0 || ^19.0.0",
|
|
@@ -21,8 +21,10 @@ export default function AssetPlaceholder({
|
|
|
21
21
|
role="img"
|
|
22
22
|
aria-label={`${label || 'asset'} — ${note}`}
|
|
23
23
|
>
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
{/* note is single-line → helper scale; label can wrap (category · name
|
|
25
|
+
* in a narrow tile) → line-height-bearing mono scale */}
|
|
26
|
+
<span className="kol-asset-placeholder-note kol-helper-12 uppercase [letter-spacing:0.12em] text-fg-48">{note}</span>
|
|
27
|
+
{label && <span className="kol-asset-placeholder-label kol-mono-12 text-fg-40 [letter-spacing:0.04em]">{label}</span>}
|
|
26
28
|
</div>
|
|
27
29
|
)
|
|
28
30
|
}
|
package/src/atoms/Avatar.jsx
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
const SIZE_MAP = {
|
|
2
|
-
sm: 'w-8 h-8
|
|
3
|
-
md: 'w-10 h-10
|
|
4
|
-
lg: 'w-14 h-14
|
|
5
|
-
xl: 'w-24 h-24
|
|
2
|
+
sm: 'w-8 h-8 kol-helper-12',
|
|
3
|
+
md: 'w-10 h-10 kol-helper-14',
|
|
4
|
+
lg: 'w-14 h-14 kol-helper-16',
|
|
5
|
+
xl: 'w-24 h-24 kol-helper-20',
|
|
6
6
|
}
|
|
7
7
|
|
|
8
8
|
export default function Avatar({ initial, size = 'sm', className = '' }) {
|
package/src/atoms/Button.jsx
CHANGED
|
@@ -117,13 +117,16 @@ const Button = ({
|
|
|
117
117
|
return renderIcon(iconOnly, iconOnlyHover)
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
-
// Button with icon(s) and text
|
|
120
|
+
// Button with icon(s) and text. Icons render directly as flex items —
|
|
121
|
+
// wrapper spans (with the old -2px optical margins) sat the glyphs on
|
|
122
|
+
// the span's text baseline instead of letting align-items center them
|
|
123
|
+
// against the label.
|
|
121
124
|
if (iconLeft || iconRight || iconLeftHover || iconRightHover) {
|
|
122
125
|
return (
|
|
123
126
|
<span className="flex items-center" style={{ gap: iconGap ?? 8 }}>
|
|
124
|
-
{(iconLeft || iconLeftHover) &&
|
|
127
|
+
{(iconLeft || iconLeftHover) && renderIcon(iconLeft, iconLeftHover)}
|
|
125
128
|
{children}
|
|
126
|
-
{(iconRight || iconRightHover) &&
|
|
129
|
+
{(iconRight || iconRightHover) && renderIcon(iconRight, iconRightHover)}
|
|
127
130
|
</span>
|
|
128
131
|
)
|
|
129
132
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* CopyButton — copy-to-clipboard chip. Clipboard icon + Copy/Copied label
|
|
5
|
+
* swap with a 1.8s reset; silent when the clipboard is blocked. Chrome
|
|
6
|
+
* comes from .kol-copy-btn (kol-theme); parents add their own positioning
|
|
7
|
+
* class (e.g. CodeBlock's .kol-codeblock-copy).
|
|
8
|
+
*
|
|
9
|
+
* Props:
|
|
10
|
+
* text — string (or () => string) written to the clipboard
|
|
11
|
+
* label — show the Copy/Copied text next to the icon (default true;
|
|
12
|
+
* false = icon-only for tight spots)
|
|
13
|
+
* className — extra classes (positioning etc.)
|
|
14
|
+
*/
|
|
15
|
+
export default function CopyButton({ text, label = true, className = '', ...props }) {
|
|
16
|
+
const [copied, setCopied] = useState(false)
|
|
17
|
+
|
|
18
|
+
const onCopy = async () => {
|
|
19
|
+
try {
|
|
20
|
+
await navigator.clipboard.writeText(typeof text === 'function' ? text() : String(text ?? ''))
|
|
21
|
+
setCopied(true)
|
|
22
|
+
setTimeout(() => setCopied(false), 1800)
|
|
23
|
+
} catch {
|
|
24
|
+
/* clipboard blocked — silent */
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<button
|
|
30
|
+
type="button"
|
|
31
|
+
className={`kol-copy-btn ${className}`.trim()}
|
|
32
|
+
onClick={onCopy}
|
|
33
|
+
aria-label={copied ? 'Copied' : 'Copy to clipboard'}
|
|
34
|
+
title={copied ? 'Copied' : 'Copy'}
|
|
35
|
+
{...props}
|
|
36
|
+
>
|
|
37
|
+
{copied ? (
|
|
38
|
+
<svg viewBox="0 0 20 20" width="14" height="14" aria-hidden="true">
|
|
39
|
+
<path d="M5 10 L9 14 L15 6" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
|
|
40
|
+
</svg>
|
|
41
|
+
) : (
|
|
42
|
+
<svg viewBox="0 0 20 20" width="14" height="14" aria-hidden="true">
|
|
43
|
+
<rect x="6" y="3" width="10" height="12" rx="1" fill="none" stroke="currentColor" strokeWidth="1.4" />
|
|
44
|
+
<rect x="4" y="6" width="10" height="12" rx="1" fill="none" stroke="currentColor" strokeWidth="1.4" />
|
|
45
|
+
</svg>
|
|
46
|
+
)}
|
|
47
|
+
{label && <span>{copied ? 'Copied' : 'Copy'}</span>}
|
|
48
|
+
</button>
|
|
49
|
+
)
|
|
50
|
+
}
|
|
@@ -16,9 +16,10 @@ const DropdownTagFilter = ({
|
|
|
16
16
|
selectedValues = new Set(),
|
|
17
17
|
onChange,
|
|
18
18
|
className = '',
|
|
19
|
+
defaultOpen = false,
|
|
19
20
|
size
|
|
20
21
|
}) => {
|
|
21
|
-
const [isOpen, setIsOpen] = useState(
|
|
22
|
+
const [isOpen, setIsOpen] = useState(defaultOpen)
|
|
22
23
|
const dropdownRef = useRef(null)
|
|
23
24
|
const [resolvedSize, setResolvedSize] = useState('md')
|
|
24
25
|
|
package/src/atoms/Input.jsx
CHANGED
|
@@ -102,10 +102,15 @@ export default function Input({
|
|
|
102
102
|
{prefix !== undefined && (
|
|
103
103
|
<span aria-hidden="true" className="text-meta pr-1 shrink-0">{prefix}</span>
|
|
104
104
|
)}
|
|
105
|
+
{/* Controlled only when a `value` prop is passed — otherwise stay
|
|
106
|
+
* uncontrolled so prop-less usages (search stubs, quick demos) type
|
|
107
|
+
* normally instead of freezing on a value-without-onChange input.
|
|
108
|
+
* Controlled + no onChange = deliberate display-only → readOnly. */}
|
|
105
109
|
<input
|
|
106
110
|
type={type}
|
|
107
|
-
|
|
108
|
-
|
|
111
|
+
{...(value !== undefined
|
|
112
|
+
? { value: value ?? '', onChange, readOnly: !onChange || undefined }
|
|
113
|
+
: { onChange })}
|
|
109
114
|
placeholder={placeholder}
|
|
110
115
|
disabled={disabled}
|
|
111
116
|
spellCheck={false}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* SegmentedToggle — N-way segmented control. Joined
|
|
3
|
-
* outer stroke; thin dividers between cells; no gap. Active cell
|
|
4
|
-
*
|
|
2
|
+
* SegmentedToggle — N-way segmented control. Joined cells sharing one
|
|
3
|
+
* outer stroke; thin dividers between cells; no gap. Active cell fills
|
|
4
|
+
* with surface-secondary + text-emphasis, inactive is text-meta with
|
|
5
|
+
* hover lifting. Chrome comes from .kol-seg* in kol-theme (NOT Tailwind
|
|
6
|
+
* utilities — those never generate from package sources).
|
|
5
7
|
*
|
|
6
8
|
* <SegmentedToggle
|
|
7
9
|
* value={current}
|
|
@@ -17,6 +19,9 @@
|
|
|
17
19
|
* Labels accept any node — pass strings or inline SVG previews. Optional
|
|
18
20
|
* `ariaLabel` per option for non-text labels.
|
|
19
21
|
*
|
|
22
|
+
* A11y: radiogroup/radio semantics with a roving tabindex — Tab enters
|
|
23
|
+
* the group on the active cell, ←/→ (or ↑/↓) move selection + focus.
|
|
24
|
+
*
|
|
20
25
|
* Props:
|
|
21
26
|
* value — current option value
|
|
22
27
|
* onChange — handler (newValue) => void
|
|
@@ -25,27 +30,41 @@
|
|
|
25
30
|
* only options (e.g. line-style previews) where text labels
|
|
26
31
|
* aren't used; the cells still center their contents but the
|
|
27
32
|
* outer height is tighter.
|
|
33
|
+
* ariaLabel — accessible name for the group
|
|
28
34
|
* className — additional classes on the outer shell
|
|
29
35
|
*/
|
|
30
|
-
export default function SegmentedToggle({ value, onChange, options = [], size = 'md', className = '' }) {
|
|
31
|
-
const
|
|
32
|
-
const
|
|
36
|
+
export default function SegmentedToggle({ value, onChange, options = [], size = 'md', ariaLabel, className = '' }) {
|
|
37
|
+
const cellType = size === 'sm' ? '' : 'kol-mono-12'
|
|
38
|
+
const focusIdx = Math.max(0, options.findIndex((opt) => opt.value === value))
|
|
39
|
+
|
|
40
|
+
const handleKeyDown = (e) => {
|
|
41
|
+
const dir = { ArrowLeft: -1, ArrowUp: -1, ArrowRight: 1, ArrowDown: 1 }[e.key]
|
|
42
|
+
if (!dir || !options.length) return
|
|
43
|
+
e.preventDefault()
|
|
44
|
+
const next = (focusIdx + dir + options.length) % options.length
|
|
45
|
+
onChange?.(options[next].value)
|
|
46
|
+
e.currentTarget.children[next]?.focus()
|
|
47
|
+
}
|
|
48
|
+
|
|
33
49
|
return (
|
|
34
|
-
<div
|
|
50
|
+
<div
|
|
51
|
+
role="radiogroup"
|
|
52
|
+
aria-label={ariaLabel}
|
|
53
|
+
onKeyDown={handleKeyDown}
|
|
54
|
+
className={['kol-seg', size === 'sm' && 'kol-seg--sm', className].filter(Boolean).join(' ')}
|
|
55
|
+
>
|
|
35
56
|
{options.map((opt, i) => {
|
|
36
57
|
const isActive = opt.value === value
|
|
37
58
|
return (
|
|
38
59
|
<button
|
|
39
60
|
key={opt.value}
|
|
40
61
|
type="button"
|
|
41
|
-
|
|
42
|
-
aria-
|
|
62
|
+
role="radio"
|
|
63
|
+
aria-checked={isActive}
|
|
43
64
|
aria-label={opt.ariaLabel}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
i > 0 ? 'border-l border-fg-04' : '',
|
|
48
|
-
].filter(Boolean).join(' ')}
|
|
65
|
+
tabIndex={i === focusIdx ? 0 : -1}
|
|
66
|
+
onClick={() => onChange?.(opt.value)}
|
|
67
|
+
className={['kol-seg-cell', cellType, isActive && 'is-active'].filter(Boolean).join(' ')}
|
|
49
68
|
>
|
|
50
69
|
{opt.label}
|
|
51
70
|
</button>
|
|
@@ -31,7 +31,7 @@ const ToggleCheckbox = ({
|
|
|
31
31
|
</span>
|
|
32
32
|
<span className="toggle-checkbox-label kol-helper-12 uppercase tracking-[0.08em]">
|
|
33
33
|
{label}
|
|
34
|
-
{hint ? <span className="ml-2 opacity-60 normal-case tracking-normal
|
|
34
|
+
{hint ? <span className="ml-2 opacity-60 normal-case tracking-normal kol-helper-10">{hint}</span> : null}
|
|
35
35
|
</span>
|
|
36
36
|
</label>
|
|
37
37
|
)
|
|
@@ -29,7 +29,7 @@ const ToggleSwitch = ({
|
|
|
29
29
|
<span className="toggle-switch-label">
|
|
30
30
|
{label}
|
|
31
31
|
{hint ? (
|
|
32
|
-
<span className="ml-2 opacity-60 normal-case tracking-normal
|
|
32
|
+
<span className="ml-2 opacity-60 normal-case tracking-normal kol-helper-10">
|
|
33
33
|
{hint}
|
|
34
34
|
</span>
|
|
35
35
|
) : null}
|
|
@@ -14,13 +14,16 @@ import { Icon } from '@kolkrabbi/kol-loader'
|
|
|
14
14
|
* where a segmented two-button toggle is overkill.
|
|
15
15
|
*
|
|
16
16
|
* Built on the .kol-control shell. Default options use grid-06 / list-01
|
|
17
|
-
* icons; consumers can pass `options` to override.
|
|
18
|
-
*
|
|
17
|
+
* icons; consumers can pass `options` to override. `iconVariant` picks the
|
|
18
|
+
* icon cut for variant="icon" ('stroke' default; 'solid' reads better at
|
|
19
|
+
* 14px). For `variant="single"`, the FIRST option in `options` is the "off"
|
|
20
|
+
* value; the SECOND is "on".
|
|
19
21
|
*/
|
|
20
22
|
const ViewToggle = ({
|
|
21
23
|
viewMode,
|
|
22
24
|
onViewChange,
|
|
23
25
|
variant = 'text',
|
|
26
|
+
iconVariant = 'stroke',
|
|
24
27
|
options = [
|
|
25
28
|
{ value: 'grid', label: 'Grid view', icon: 'grid-06' },
|
|
26
29
|
{ value: 'list', label: 'List view', icon: 'list-01' }
|
|
@@ -88,7 +91,7 @@ const ViewToggle = ({
|
|
|
88
91
|
title={option.label}
|
|
89
92
|
>
|
|
90
93
|
{isIconVariant && option.icon ? (
|
|
91
|
-
<Icon name={option.icon} size={14} />
|
|
94
|
+
<Icon name={option.icon} size={14} variant={iconVariant} />
|
|
92
95
|
) : (
|
|
93
96
|
option.label
|
|
94
97
|
)}
|
package/src/graphics/Graphic.jsx
CHANGED
|
@@ -4,29 +4,54 @@
|
|
|
4
4
|
* Globs ./svg/<category>/<name>.svg at build time. Usage:
|
|
5
5
|
* <Graphic category="patterns" name="pattern-05" />
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* The raw SVG strings (~4.8 MB) live in ./graphicData.js behind a single
|
|
8
|
+
* dynamic import — their chunk streams in parallel with boot instead of
|
|
9
|
+
* bloating the consumer's entry chunk (same pattern as kol-loader's Icon).
|
|
10
|
+
* Until the chunk lands, renders a sized empty box (no layout shift); a
|
|
11
|
+
* genuinely missing asset renders an AssetPlaceholder so it's visible rather
|
|
12
|
+
* than silently empty.
|
|
10
13
|
*/
|
|
11
|
-
import
|
|
14
|
+
import { useEffect, useState } from 'react'
|
|
15
|
+
import AssetPlaceholder from '../atoms/AssetPlaceholder.jsx'
|
|
12
16
|
|
|
13
|
-
|
|
17
|
+
let RAW = null // category → name → raw svg, once the chunk resolves
|
|
18
|
+
let loadPromise = null
|
|
19
|
+
const subscribers = new Set()
|
|
14
20
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
21
|
+
const loadGraphics = () => {
|
|
22
|
+
if (!loadPromise) {
|
|
23
|
+
loadPromise = import('./graphicData.js').then((mod) => {
|
|
24
|
+
RAW = mod.GRAPHIC_RAW
|
|
25
|
+
subscribers.forEach((fn) => fn())
|
|
26
|
+
return mod
|
|
27
|
+
})
|
|
28
|
+
}
|
|
29
|
+
return loadPromise
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Start fetching at module eval — in flight alongside the rest of boot.
|
|
33
|
+
loadGraphics()
|
|
34
|
+
|
|
35
|
+
const useGraphicsReady = () => {
|
|
36
|
+
const [ready, setReady] = useState(() => !!RAW)
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
if (RAW) return undefined
|
|
39
|
+
const cb = () => setReady(true)
|
|
40
|
+
subscribers.add(cb)
|
|
41
|
+
loadGraphics()
|
|
42
|
+
return () => subscribers.delete(cb)
|
|
43
|
+
}, [])
|
|
44
|
+
return ready
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/* Inventory (category → sorted names) from a keys-only glob — paths resolve at
|
|
48
|
+
* build time without pulling any SVG content into the chunk. */
|
|
49
|
+
export const GRAPHICS = Object.keys(import.meta.glob('./svg/**/*.svg')).reduce((acc, path) => {
|
|
18
50
|
const [category, file] = path.replace('./svg/', '').split('/')
|
|
19
|
-
|
|
20
|
-
if (!acc[category]) acc[category] = {}
|
|
21
|
-
acc[category][name] = svg
|
|
51
|
+
;(acc[category] ||= []).push(file.replace('.svg', ''))
|
|
22
52
|
return acc
|
|
23
53
|
}, {})
|
|
24
|
-
|
|
25
|
-
const GRAPHIC_CACHE = GRAPHIC_RAW
|
|
26
|
-
|
|
27
|
-
export const GRAPHICS = Object.fromEntries(
|
|
28
|
-
Object.entries(GRAPHIC_CACHE).map(([category, items]) => [category, Object.keys(items).sort()])
|
|
29
|
-
)
|
|
54
|
+
for (const names of Object.values(GRAPHICS)) names.sort()
|
|
30
55
|
|
|
31
56
|
export default function Graphic({
|
|
32
57
|
category,
|
|
@@ -36,7 +61,20 @@ export default function Graphic({
|
|
|
36
61
|
title,
|
|
37
62
|
aspectRatio = '1 / 1',
|
|
38
63
|
}) {
|
|
39
|
-
const
|
|
64
|
+
const ready = useGraphicsReady()
|
|
65
|
+
|
|
66
|
+
// Chunk still streaming — hold a same-sized box so layout doesn't shift.
|
|
67
|
+
if (!ready) {
|
|
68
|
+
return (
|
|
69
|
+
<span
|
|
70
|
+
aria-hidden="true"
|
|
71
|
+
className={`kol-graphic inline-flex w-full h-auto ${className}`.trim()}
|
|
72
|
+
style={{ aspectRatio, ...style }}
|
|
73
|
+
/>
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const raw = RAW[category]?.[name]
|
|
40
78
|
if (!raw) {
|
|
41
79
|
if (import.meta.env.DEV) console.warn(`Graphic: ${category}/${name} not found`)
|
|
42
80
|
return <AssetPlaceholder category={category} name={name} aspectRatio={aspectRatio} note="pending" className={className} />
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* graphicData — the raw SVG map behind <Graphic>.
|
|
3
|
+
*
|
|
4
|
+
* ~4.8 MB of illustration SVGs (some wrap embedded raster images) eager-inline
|
|
5
|
+
* here, but this module is only reached via the dynamic `import()` in
|
|
6
|
+
* Graphic.jsx — the bundler splits it into its own async chunk instead of
|
|
7
|
+
* folding it into the consumer's entry chunk. Same pattern as kol-loader's
|
|
8
|
+
* iconData.js (kol-labs-single 2026-06-19 entry-chunk fix).
|
|
9
|
+
*/
|
|
10
|
+
const svgModules = import.meta.glob('./svg/**/*.svg', { eager: true, query: '?raw', import: 'default' })
|
|
11
|
+
|
|
12
|
+
// category → name → raw SVG string.
|
|
13
|
+
export const GRAPHIC_RAW = Object.entries(svgModules).reduce((acc, [path, svg]) => {
|
|
14
|
+
const [category, file] = path.replace('./svg/', '').split('/')
|
|
15
|
+
const name = file.replace('.svg', '')
|
|
16
|
+
if (!acc[category]) acc[category] = {}
|
|
17
|
+
acc[category][name] = svg
|
|
18
|
+
return acc
|
|
19
|
+
}, {})
|
package/src/index.js
CHANGED
|
@@ -1,59 +1,74 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @kol/component - Canonical KOL design-system primitives
|
|
3
3
|
*
|
|
4
|
-
* Shared atoms/molecules consumed by both apps/web (via @kol/ui
|
|
5
|
-
* and apps/brand. Components emit canonical kol-* classes; CSS
|
|
6
|
-
* @kol/theme (kol-components-*.css).
|
|
4
|
+
* Shared atoms/molecules/organisms consumed by both apps/web (via @kol/ui
|
|
5
|
+
* re-export) and apps/brand. Components emit canonical kol-* classes; CSS
|
|
6
|
+
* lives in @kol/theme (kol-components-*.css).
|
|
7
|
+
*
|
|
8
|
+
* Placement follows the taxonomy rules in
|
|
9
|
+
* docs/taxonomy/01-component-placement.md:
|
|
10
|
+
* atom — nests no KOL component (kol-loader Icon/Graphic are
|
|
11
|
+
* infrastructure, they don't count)
|
|
12
|
+
* molecule — nests at least one KOL component
|
|
13
|
+
* organism — a self-contained composed UI region
|
|
14
|
+
* `scripts/validate-taxonomy.mjs` enforces the closed folder set and the
|
|
15
|
+
* downward-only import rule (atoms never import molecules/organisms).
|
|
7
16
|
*/
|
|
8
17
|
|
|
9
|
-
export { default as Divider } from './atoms/Divider.jsx'
|
|
10
|
-
export { default as DropdownTagFilter } from './molecules/DropdownTagFilter.jsx'
|
|
11
|
-
export { default as QuantityInput } from './molecules/QuantityInput.jsx'
|
|
12
|
-
export { default as QuantityStepper } from './molecules/QuantityStepper.jsx'
|
|
13
|
-
export { default as Pill } from './molecules/Pill.jsx'
|
|
14
|
-
export { default as ToggleCheckbox } from './atoms/ToggleCheckbox.jsx'
|
|
15
|
-
export { default as ToggleSwitch } from './atoms/ToggleSwitch.jsx'
|
|
16
|
-
export { default as ToggleBracket } from './molecules/ToggleBracket.jsx'
|
|
17
|
-
export { Icon } from '@kolkrabbi/kol-loader'
|
|
18
|
-
export { default as Tag } from './molecules/Tag.jsx'
|
|
19
|
-
export { default as Badge } from './molecules/Badge.jsx'
|
|
20
|
-
export { default as SectionLabel } from './molecules/SectionLabel.jsx'
|
|
21
|
-
export { default as Section } from './molecules/Section.jsx'
|
|
22
|
-
export { default as Button } from './atoms/Button.jsx'
|
|
23
|
-
export { default as Input } from './atoms/Input.jsx'
|
|
24
|
-
export { default as Slider } from './atoms/Slider.jsx'
|
|
25
|
-
export { default as Dropdown } from './molecules/Dropdown.jsx'
|
|
26
|
-
export { usePopover, PopoverPanel, Tooltip } from './molecules/Popover.jsx'
|
|
27
|
-
export { MenuItem, MenuDropdownItem, MenuDropdownDivider, MenuDropdownNest } from './molecules/MenuItem.jsx'
|
|
28
|
-
|
|
29
|
-
/* ── Folded from apps/brand (Phase 4 — brand is canonical, brand wins) ────── */
|
|
30
18
|
// atoms
|
|
19
|
+
export { default as AssetPlaceholder } from './atoms/AssetPlaceholder.jsx'
|
|
31
20
|
export { default as Avatar } from './atoms/Avatar.jsx'
|
|
32
|
-
export { default as
|
|
21
|
+
export { default as Badge } from './atoms/Badge.jsx'
|
|
22
|
+
export { default as Button } from './atoms/Button.jsx'
|
|
23
|
+
export { default as CopyButton } from './atoms/CopyButton.jsx'
|
|
24
|
+
export { default as Divider } from './atoms/Divider.jsx'
|
|
25
|
+
export { default as DropdownTagFilter } from './atoms/DropdownTagFilter.jsx'
|
|
26
|
+
export { default as ExitPreview } from './atoms/ExitPreview.jsx'
|
|
27
|
+
export { default as FullscreenOverlay } from './atoms/FullscreenOverlay.jsx'
|
|
28
|
+
export { default as Input } from './atoms/Input.jsx'
|
|
33
29
|
export { default as Label } from './atoms/Label.jsx'
|
|
30
|
+
export { default as LabeledControl } from './atoms/LabeledControl.jsx'
|
|
31
|
+
export { default as Pill } from './atoms/Pill.jsx'
|
|
32
|
+
export { usePopover, PopoverPanel, Tooltip } from './atoms/Popover.jsx'
|
|
33
|
+
export { default as QuantityInput } from './atoms/QuantityInput.jsx'
|
|
34
|
+
export { default as QuantityStepper } from './atoms/QuantityStepper.jsx'
|
|
35
|
+
export { default as Section } from './atoms/Section.jsx'
|
|
36
|
+
export { default as SectionLabel } from './atoms/SectionLabel.jsx'
|
|
37
|
+
export { default as SegmentedToggle } from './atoms/SegmentedToggle.jsx'
|
|
34
38
|
export { default as Stepper } from './atoms/Stepper.jsx'
|
|
39
|
+
export { default as Tag } from './atoms/Tag.jsx'
|
|
35
40
|
export { default as Textarea } from './atoms/Textarea.jsx'
|
|
41
|
+
export { default as ToggleBracket } from './atoms/ToggleBracket.jsx'
|
|
42
|
+
export { default as ToggleCheckbox } from './atoms/ToggleCheckbox.jsx'
|
|
43
|
+
export { default as ToggleSwitch } from './atoms/ToggleSwitch.jsx'
|
|
36
44
|
export { default as TransparentX } from './atoms/TransparentX.jsx'
|
|
45
|
+
export { default as ViewToggle } from './atoms/ViewToggle.jsx'
|
|
46
|
+
|
|
37
47
|
// molecules
|
|
38
|
-
export {
|
|
39
|
-
export { default as
|
|
48
|
+
export { Accordion, AccordionPanel } from './molecules/Accordion.jsx'
|
|
49
|
+
export { default as CodeBlock } from './molecules/CodeBlock.jsx'
|
|
50
|
+
export { default as ColorSwatch } from './molecules/ColorSwatch.jsx'
|
|
51
|
+
export { default as Dropdown } from './molecules/Dropdown.jsx'
|
|
52
|
+
export { default as Image } from './molecules/Image.jsx'
|
|
53
|
+
export { default as MediaCard } from './molecules/MediaCard.jsx'
|
|
54
|
+
export { default as MediaRow } from './molecules/MediaRow.jsx'
|
|
55
|
+
export { MenuItem, MenuDropdownItem, MenuDropdownDivider, MenuDropdownNest } from './molecules/MenuItem.jsx'
|
|
40
56
|
export { MenuPopover } from './molecules/MenuPopover.jsx'
|
|
41
57
|
export { ModalProvider, useModal } from './molecules/Modal.jsx'
|
|
42
58
|
export { default as PropertyInput } from './molecules/PropertyInput.jsx'
|
|
43
|
-
export { default as
|
|
44
|
-
|
|
45
|
-
// primitives
|
|
46
|
-
export { Accordion, AccordionPanel } from './primitives/Accordion.jsx'
|
|
47
|
-
export { default as AssetPlaceholder } from './primitives/AssetPlaceholder.jsx'
|
|
48
|
-
export { default as Carousel } from './primitives/Carousel.jsx'
|
|
49
|
-
export { default as CodeBlock } from './primitives/CodeBlock.jsx'
|
|
50
|
-
export { default as ExitPreview } from './primitives/ExitPreview.jsx'
|
|
51
|
-
export { default as FullscreenOverlay } from './primitives/FullscreenOverlay.jsx'
|
|
52
|
-
export { default as Image } from './primitives/Image.jsx'
|
|
53
|
-
// graphics (SVG illustration loader — globs its own ./graphics/svg/**)
|
|
54
|
-
export { default as Graphic, GRAPHICS, GRAPHIC_RAW } from './graphics/Graphic.jsx'
|
|
59
|
+
export { default as Slider } from './molecules/Slider.jsx'
|
|
60
|
+
|
|
55
61
|
// organisms
|
|
62
|
+
export { default as Carousel } from './organisms/Carousel.jsx'
|
|
63
|
+
export { default as ContentFilters } from './organisms/ContentFilters.jsx'
|
|
56
64
|
export { default as Table } from './organisms/Table.jsx'
|
|
65
|
+
|
|
66
|
+
// loaders (re-export — infrastructure, documented on /docs/loaders)
|
|
67
|
+
export { Icon } from '@kolkrabbi/kol-loader'
|
|
68
|
+
|
|
69
|
+
// graphics (SVG illustration loader — globs its own ./graphics/svg/**)
|
|
70
|
+
export { default as Graphic, GRAPHICS } from './graphics/Graphic.jsx'
|
|
71
|
+
|
|
57
72
|
// hooks
|
|
58
73
|
export { default as useReveal } from './hooks/useReveal.js'
|
|
59
74
|
export { default as useScrollSpy } from './hooks/useScrollSpy.js'
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Accordion — collapsible panel group.
|
|
3
3
|
*
|
|
4
|
+
* taxonomy-ok: molecule — Accordion nests AccordionPanel in this same file,
|
|
5
|
+
* which the import-based validator can't see.
|
|
6
|
+
*
|
|
4
7
|
* Composition-based: <Accordion> wraps <AccordionPanel> children. Each panel
|
|
5
8
|
* owns its open/closed state independently (additive — multiple can be open).
|
|
6
9
|
* For single-open behavior, manage state from the parent and pass controlled
|
|
@@ -37,7 +40,7 @@ export function AccordionPanel({
|
|
|
37
40
|
>
|
|
38
41
|
<span className="kol-accordion-title kol-helper-12 uppercase tracking-widest">{title}</span>
|
|
39
42
|
{meta && <span className="kol-accordion-meta kol-helper-12 uppercase tracking-widest text-fg-48 ml-auto mr-3">{meta}</span>}
|
|
40
|
-
<span className="kol-accordion-chevron
|
|
43
|
+
<span className="kol-accordion-chevron kol-helper-16 text-fg-48 min-w-3 transition-colors duration-[120ms] ml-auto" aria-hidden="true">{open ? '−' : '+'}</span>
|
|
41
44
|
</button>
|
|
42
45
|
{open && <div className="kol-accordion-body pt-2 pb-6">{children}</div>}
|
|
43
46
|
</div>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import CopyButton from '../atoms/CopyButton.jsx'
|
|
2
|
+
|
|
3
|
+
export default function CodeBlock({ children, language }) {
|
|
4
|
+
return (
|
|
5
|
+
<div className="kol-codeblock">
|
|
6
|
+
{language && <span className="kol-codeblock-lang">{language}</span>}
|
|
7
|
+
<CopyButton text={String(children)} className="kol-codeblock-copy" />
|
|
8
|
+
<pre><code>{children}</code></pre>
|
|
9
|
+
</div>
|
|
10
|
+
)
|
|
11
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useEffect, useState } from 'react'
|
|
2
2
|
import { Icon } from '@kolkrabbi/kol-loader'
|
|
3
3
|
import { MenuDropdownItem } from './MenuItem.jsx'
|
|
4
|
-
import { PopoverPanel, usePopover } from '
|
|
4
|
+
import { PopoverPanel, usePopover } from '../atoms/Popover.jsx'
|
|
5
5
|
|
|
6
6
|
const SIZE_MAP = {
|
|
7
7
|
sm: { fontSize: 12, paddingY: 4, paddingX: 12, radius: 14, panelRadius: 14, icon: 10 },
|
|
@@ -17,9 +17,10 @@ const Dropdown = ({
|
|
|
17
17
|
onChange,
|
|
18
18
|
size,
|
|
19
19
|
variant = 'default',
|
|
20
|
+
defaultOpen = false,
|
|
20
21
|
className = ''
|
|
21
22
|
}) => {
|
|
22
|
-
const [isOpen, setIsOpen] = useState(
|
|
23
|
+
const [isOpen, setIsOpen] = useState(defaultOpen)
|
|
23
24
|
const [resolvedSize, setResolvedSize] = useState('md')
|
|
24
25
|
const [dropdownWidth, setDropdownWidth] = useState('100px')
|
|
25
26
|
|
|
@@ -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
|
/**
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { useState, useMemo, useRef, useEffect } from 'react'
|
|
2
|
-
import Tag from '
|
|
2
|
+
import Tag from '../atoms/Tag.jsx'
|
|
3
3
|
import Divider from '../atoms/Divider.jsx'
|
|
4
4
|
import { Icon } from '@kolkrabbi/kol-loader'
|
|
5
|
-
import ViewToggle from '
|
|
5
|
+
import ViewToggle from '../atoms/ViewToggle'
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* ContentFilters — universal filter component for content grids.
|
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
import { useState } from 'react'
|
|
2
|
-
|
|
3
|
-
export default function CodeBlock({ children, language }) {
|
|
4
|
-
const [copied, setCopied] = useState(false)
|
|
5
|
-
|
|
6
|
-
const onCopy = async () => {
|
|
7
|
-
try {
|
|
8
|
-
await navigator.clipboard.writeText(String(children))
|
|
9
|
-
setCopied(true)
|
|
10
|
-
setTimeout(() => setCopied(false), 1800)
|
|
11
|
-
} catch {
|
|
12
|
-
/* clipboard blocked — silent */
|
|
13
|
-
}
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
return (
|
|
17
|
-
<div className="kol-codeblock">
|
|
18
|
-
{language && <span className="kol-codeblock-lang">{language}</span>}
|
|
19
|
-
<button
|
|
20
|
-
type="button"
|
|
21
|
-
className="kol-codeblock-copy"
|
|
22
|
-
onClick={onCopy}
|
|
23
|
-
aria-label={copied ? 'Copied' : 'Copy to clipboard'}
|
|
24
|
-
title={copied ? 'Copied' : 'Copy'}
|
|
25
|
-
>
|
|
26
|
-
{copied ? (
|
|
27
|
-
<svg viewBox="0 0 20 20" width="14" height="14" aria-hidden="true">
|
|
28
|
-
<path d="M5 10 L9 14 L15 6" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
|
|
29
|
-
</svg>
|
|
30
|
-
) : (
|
|
31
|
-
<svg viewBox="0 0 20 20" width="14" height="14" aria-hidden="true">
|
|
32
|
-
<rect x="6" y="3" width="10" height="12" rx="1" fill="none" stroke="currentColor" strokeWidth="1.4" />
|
|
33
|
-
<rect x="4" y="6" width="10" height="12" rx="1" fill="none" stroke="currentColor" strokeWidth="1.4" />
|
|
34
|
-
</svg>
|
|
35
|
-
)}
|
|
36
|
-
<span className="leading-none">{copied ? 'Copied' : 'Copy'}</span>
|
|
37
|
-
</button>
|
|
38
|
-
<pre><code>{children}</code></pre>
|
|
39
|
-
</div>
|
|
40
|
-
)
|
|
41
|
-
}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|