@kolkrabbi/kol-component 0.1.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/README.md +24 -0
- package/package.json +46 -0
- package/src/atoms/Avatar.jsx +17 -0
- package/src/atoms/Button.jsx +172 -0
- package/src/atoms/ColorSwatch.jsx +126 -0
- package/src/atoms/Divider.jsx +34 -0
- package/src/atoms/Input.jsx +121 -0
- package/src/atoms/Label.jsx +12 -0
- package/src/atoms/Slider.jsx +129 -0
- package/src/atoms/Stepper.jsx +97 -0
- package/src/atoms/Textarea.jsx +70 -0
- package/src/atoms/ToggleCheckbox.jsx +40 -0
- package/src/atoms/ToggleSwitch.jsx +42 -0
- package/src/atoms/TransparentX.jsx +37 -0
- package/src/graphics/Graphic.jsx +53 -0
- package/src/graphics/svg/abstract/abstract-01.svg +3 -0
- package/src/graphics/svg/abstract/abstract-02.svg +3 -0
- package/src/graphics/svg/abstract/abstract-03.svg +3 -0
- package/src/graphics/svg/abstract/abstract-06.svg +4 -0
- package/src/graphics/svg/diagrams/.gitkeep +0 -0
- package/src/graphics/svg/diagrams/ac-structure.svg +90 -0
- package/src/graphics/svg/diagrams/rg-x-height.svg +3 -0
- package/src/graphics/svg/diagrams/structure-grid.svg +618 -0
- package/src/graphics/svg/diagrams/structure-logo.svg +23 -0
- package/src/graphics/svg/patterns/.gitkeep +0 -0
- package/src/graphics/svg/patterns/patt-01.svg +34 -0
- package/src/graphics/svg/patterns/patt-02.svg +34 -0
- package/src/graphics/svg/patterns/patt-03.svg +110 -0
- package/src/graphics/svg/patterns/patt-04.svg +10 -0
- package/src/graphics/svg/patterns/patt-05.svg +62 -0
- package/src/graphics/svg/patterns/patt-06.svg +66 -0
- package/src/graphics/svg/patterns/patt-07.svg +130 -0
- package/src/graphics/svg/patterns/patt-08.svg +434 -0
- package/src/graphics/svg/patterns/patt-09.svg +38 -0
- package/src/graphics/svg/patterns/patt-10.svg +20 -0
- package/src/graphics/svg/patterns/patt-11.svg +38 -0
- package/src/graphics/svg/patterns/patt-12.svg +58 -0
- package/src/graphics/svg/patterns/patt-13.svg +48 -0
- package/src/graphics/svg/patterns/patt-14.svg +90 -0
- package/src/graphics/svg/patterns/patt-15.svg +194 -0
- package/src/graphics/svg/patterns/patt-16.svg +12 -0
- package/src/graphics/svg/social/social-01.svg +18 -0
- package/src/graphics/svg/social/social-02.svg +18 -0
- package/src/graphics/svg/social/social-03.svg +18 -0
- package/src/graphics/svg/social/social-04.svg +18 -0
- package/src/graphics/svg/social/social-05.svg +18 -0
- package/src/graphics/svg/social/social-06.svg +18 -0
- package/src/graphics/svg/social/social-07.svg +22 -0
- package/src/graphics/svg/social/social-08.svg +22 -0
- package/src/graphics/svg/social/social-09.svg +22 -0
- package/src/graphics/svg/social/social-10.svg +6 -0
- package/src/graphics/svg/social/social-11.svg +6 -0
- package/src/graphics/svg/social/social-12.svg +6 -0
- package/src/graphics/svg/social/social-13.svg +9 -0
- package/src/graphics/svg/social/social-14.svg +9 -0
- package/src/graphics/svg/social/social-15.svg +9 -0
- package/src/graphics/svg/structure/diagram-grid-lockup-hori.svg +23 -0
- package/src/graphics/svg/structure/diagram-grid-lockup-vert.svg +25 -0
- package/src/graphics/svg/structure/diagram-grid-logomark.svg +20 -0
- package/src/graphics/svg/structure/diagram-grid-wordmark.svg +18 -0
- package/src/graphics/svg/structure/diagram-logo-lockup-hori.svg +9 -0
- package/src/graphics/svg/structure/diagram-logo-lockup-vert.svg +23 -0
- package/src/graphics/svg/structure/diagram-logo-logomark.svg +7 -0
- package/src/graphics/svg/structure/diagram-logo-wordmark.svg +3 -0
- package/src/graphics/svg/structure/diagram-x-lockup-hori.svg +5 -0
- package/src/graphics/svg/structure/diagram-x-lockup-vert.svg +10 -0
- package/src/graphics/svg/structure/diagram-x-logomark.svg +5 -0
- package/src/graphics/svg/structure/diagram-x-wordmark.svg +5 -0
- package/src/hooks/useReveal.js +28 -0
- package/src/hooks/useScrollSpy.js +56 -0
- package/src/index.js +59 -0
- package/src/molecules/Badge.jsx +51 -0
- package/src/molecules/ContentFilters.jsx +263 -0
- package/src/molecules/Dropdown.jsx +223 -0
- package/src/molecules/DropdownTagFilter.jsx +253 -0
- package/src/molecules/LabeledControl.jsx +66 -0
- package/src/molecules/MenuItem.jsx +148 -0
- package/src/molecules/MenuPopover.jsx +128 -0
- package/src/molecules/Modal.jsx +124 -0
- package/src/molecules/Pill.jsx +33 -0
- package/src/molecules/Popover.jsx +208 -0
- package/src/molecules/PropertyInput.jsx +32 -0
- package/src/molecules/QuantityInput.jsx +174 -0
- package/src/molecules/QuantityStepper.jsx +149 -0
- package/src/molecules/Section.jsx +16 -0
- package/src/molecules/SectionLabel.jsx +59 -0
- package/src/molecules/SegmentedToggle.jsx +56 -0
- package/src/molecules/Tag.jsx +79 -0
- package/src/molecules/ToggleBracket.jsx +45 -0
- package/src/molecules/ViewToggle.jsx +101 -0
- package/src/organisms/Table.jsx +46 -0
- package/src/primitives/Accordion.jsx +45 -0
- package/src/primitives/AssetPlaceholder.jsx +28 -0
- package/src/primitives/Carousel.jsx +50 -0
- package/src/primitives/CodeBlock.jsx +41 -0
- package/src/primitives/ExitPreview.jsx +12 -0
- package/src/primitives/FullscreenOverlay.jsx +39 -0
- package/src/primitives/Image.jsx +38 -0
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
// Match Dropdown SIZE_MAP for consistent height
|
|
4
|
+
const SIZE_MAP = {
|
|
5
|
+
sm: { fontSize: 11, paddingY: 12, paddingX: 24, radius: 20 },
|
|
6
|
+
md: { fontSize: 12, paddingY: 14, paddingX: 24, radius: 22 },
|
|
7
|
+
lg: { fontSize: 14, paddingY: 16, paddingX: 24, radius: 24 }
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const QuantityStepper = ({
|
|
11
|
+
value = 1,
|
|
12
|
+
onChange,
|
|
13
|
+
min = 1,
|
|
14
|
+
max = 10,
|
|
15
|
+
size,
|
|
16
|
+
className = ''
|
|
17
|
+
}) => {
|
|
18
|
+
const [resolvedSize, setResolvedSize] = useState('md')
|
|
19
|
+
const [componentWidth, setComponentWidth] = useState('180px')
|
|
20
|
+
|
|
21
|
+
// Responsive size
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
const determineSize = () => {
|
|
24
|
+
if (size) {
|
|
25
|
+
setResolvedSize(size)
|
|
26
|
+
return
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (typeof window === 'undefined') {
|
|
30
|
+
setResolvedSize('md')
|
|
31
|
+
return
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (window.innerWidth >= 1024) {
|
|
35
|
+
setResolvedSize('lg')
|
|
36
|
+
} else if (window.innerWidth >= 768) {
|
|
37
|
+
setResolvedSize('md')
|
|
38
|
+
} else {
|
|
39
|
+
setResolvedSize('sm')
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
determineSize()
|
|
44
|
+
window.addEventListener('resize', determineSize)
|
|
45
|
+
return () => window.removeEventListener('resize', determineSize)
|
|
46
|
+
}, [size])
|
|
47
|
+
|
|
48
|
+
// Width management - match Dropdown
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
const updateWidth = () => {
|
|
51
|
+
if (typeof window === 'undefined') return
|
|
52
|
+
|
|
53
|
+
if (window.innerWidth >= 1024) {
|
|
54
|
+
setComponentWidth('180px')
|
|
55
|
+
} else if (window.innerWidth >= 768) {
|
|
56
|
+
setComponentWidth('140px')
|
|
57
|
+
} else {
|
|
58
|
+
setComponentWidth('100px')
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
updateWidth()
|
|
62
|
+
window.addEventListener('resize', updateWidth)
|
|
63
|
+
return () => window.removeEventListener('resize', updateWidth)
|
|
64
|
+
}, [])
|
|
65
|
+
|
|
66
|
+
const metrics = SIZE_MAP[resolvedSize] || SIZE_MAP.md
|
|
67
|
+
|
|
68
|
+
const decrement = () => {
|
|
69
|
+
if (value > min) onChange?.(value - 1)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const increment = () => {
|
|
73
|
+
if (value < max) onChange?.(value + 1)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const buttonStyle = {
|
|
77
|
+
padding: `${metrics.paddingY}px 12px`,
|
|
78
|
+
display: 'flex',
|
|
79
|
+
alignItems: 'center',
|
|
80
|
+
justifyContent: 'center',
|
|
81
|
+
backgroundColor: 'transparent',
|
|
82
|
+
border: 'none',
|
|
83
|
+
color: 'var(--kol-surface-on-primary)',
|
|
84
|
+
cursor: 'pointer',
|
|
85
|
+
transition: 'opacity 0.15s',
|
|
86
|
+
fontFamily: 'var(--kol-font-family-mono)',
|
|
87
|
+
fontSize: `${metrics.fontSize}px`,
|
|
88
|
+
lineHeight: '120%'
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const disabledStyle = {
|
|
92
|
+
opacity: 0.3,
|
|
93
|
+
cursor: 'not-allowed'
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<div
|
|
98
|
+
className={`flex items-center justify-center ${className}`}
|
|
99
|
+
style={{
|
|
100
|
+
width: componentWidth,
|
|
101
|
+
minWidth: componentWidth,
|
|
102
|
+
border: '1px solid var(--kol-border-default)',
|
|
103
|
+
borderRadius: `${metrics.radius}px`,
|
|
104
|
+
backgroundColor: 'var(--kol-surface-primary)'
|
|
105
|
+
}}
|
|
106
|
+
>
|
|
107
|
+
<button
|
|
108
|
+
type="button"
|
|
109
|
+
onClick={decrement}
|
|
110
|
+
disabled={value <= min}
|
|
111
|
+
style={{
|
|
112
|
+
...buttonStyle,
|
|
113
|
+
...(value <= min ? disabledStyle : {})
|
|
114
|
+
}}
|
|
115
|
+
aria-label="Decrease quantity"
|
|
116
|
+
>
|
|
117
|
+
−
|
|
118
|
+
</button>
|
|
119
|
+
|
|
120
|
+
<span
|
|
121
|
+
style={{
|
|
122
|
+
minWidth: '24px',
|
|
123
|
+
textAlign: 'center',
|
|
124
|
+
color: 'var(--kol-surface-on-primary)',
|
|
125
|
+
fontFamily: 'var(--kol-font-family-mono)',
|
|
126
|
+
fontSize: `${metrics.fontSize}px`,
|
|
127
|
+
lineHeight: '120%'
|
|
128
|
+
}}
|
|
129
|
+
>
|
|
130
|
+
{value}
|
|
131
|
+
</span>
|
|
132
|
+
|
|
133
|
+
<button
|
|
134
|
+
type="button"
|
|
135
|
+
onClick={increment}
|
|
136
|
+
disabled={value >= max}
|
|
137
|
+
style={{
|
|
138
|
+
...buttonStyle,
|
|
139
|
+
...(value >= max ? disabledStyle : {})
|
|
140
|
+
}}
|
|
141
|
+
aria-label="Increase quantity"
|
|
142
|
+
>
|
|
143
|
+
+
|
|
144
|
+
</button>
|
|
145
|
+
</div>
|
|
146
|
+
)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export default QuantityStepper
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Section — labeled control group for inspector/editor panels.
|
|
3
|
+
*
|
|
4
|
+
* A small-caps label above a vertical content stack. Used across the editor
|
|
5
|
+
* inspector panels (palette / pattern / type modes): `<Section label="Aspect">…</Section>`.
|
|
6
|
+
*/
|
|
7
|
+
export default function Section({ label, children, className = '' }) {
|
|
8
|
+
return (
|
|
9
|
+
<div className={`flex flex-col gap-2 ${className}`}>
|
|
10
|
+
{label && (
|
|
11
|
+
<p className="kol-helper-10 uppercase tracking-widest text-meta">{label}</p>
|
|
12
|
+
)}
|
|
13
|
+
{children}
|
|
14
|
+
</div>
|
|
15
|
+
)
|
|
16
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { Icon } from '@kolkrabbi/kol-loader'
|
|
2
|
+
|
|
3
|
+
export default function SectionLabel({
|
|
4
|
+
text,
|
|
5
|
+
size = 'md',
|
|
6
|
+
className = ''
|
|
7
|
+
}) {
|
|
8
|
+
// Size variants: sm (16px), md (20px), lg (32px)
|
|
9
|
+
const sizeConfig = {
|
|
10
|
+
sm: {
|
|
11
|
+
height: 'h-4',
|
|
12
|
+
iconSize: 16,
|
|
13
|
+
textClass: 'kol-label-compact-md'
|
|
14
|
+
},
|
|
15
|
+
md: {
|
|
16
|
+
height: 'h-5',
|
|
17
|
+
iconSize: 24,
|
|
18
|
+
textClass: 'kol-label-compact-lg'
|
|
19
|
+
},
|
|
20
|
+
lg: {
|
|
21
|
+
height: 'h-8',
|
|
22
|
+
iconSize: 40,
|
|
23
|
+
textClass: 'kol-heading-md'
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const config = sizeConfig[size] || sizeConfig.md
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<div className={`section-label-wrapper flex flex-row items-center gap-1 overflow-visible ${config.height} ${className}`}>
|
|
31
|
+
<p className={`${config.textClass} text-auto`}>{text}</p>
|
|
32
|
+
<span
|
|
33
|
+
className="icon-swap-container"
|
|
34
|
+
style={{
|
|
35
|
+
position: 'relative',
|
|
36
|
+
display: 'inline-flex',
|
|
37
|
+
alignItems: 'center',
|
|
38
|
+
justifyContent: 'center',
|
|
39
|
+
width: `${config.iconSize}px`,
|
|
40
|
+
height: `${config.iconSize}px`,
|
|
41
|
+
overflow: 'hidden'
|
|
42
|
+
}}
|
|
43
|
+
>
|
|
44
|
+
<Icon
|
|
45
|
+
name="arrow-downright"
|
|
46
|
+
size={config.iconSize}
|
|
47
|
+
className="icon-default"
|
|
48
|
+
style={{ position: 'absolute' }}
|
|
49
|
+
/>
|
|
50
|
+
<Icon
|
|
51
|
+
name="arrow-downright"
|
|
52
|
+
size={config.iconSize}
|
|
53
|
+
className="icon-hover"
|
|
54
|
+
style={{ position: 'absolute' }}
|
|
55
|
+
/>
|
|
56
|
+
</span>
|
|
57
|
+
</div>
|
|
58
|
+
)
|
|
59
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SegmentedToggle — N-way segmented control. Joined buttons sharing one
|
|
3
|
+
* outer stroke; thin dividers between cells; no gap. Active cell uses
|
|
4
|
+
* bg-fg-04 + text-emphasis, inactive is text-meta with hover lifting.
|
|
5
|
+
*
|
|
6
|
+
* <SegmentedToggle
|
|
7
|
+
* value={current}
|
|
8
|
+
* onChange={setCurrent}
|
|
9
|
+
* options={[{ value, label }]}
|
|
10
|
+
* />
|
|
11
|
+
*
|
|
12
|
+
* Companion to `ViewToggle`:
|
|
13
|
+
* - ViewToggle (text) — bare segmented buttons separated by gap; no shared shell.
|
|
14
|
+
* - ViewToggle (icon) — inset-well row of square icon buttons.
|
|
15
|
+
* - SegmentedToggle — flat segmented strip with shared border + dividers.
|
|
16
|
+
*
|
|
17
|
+
* Labels accept any node — pass strings or inline SVG previews. Optional
|
|
18
|
+
* `ariaLabel` per option for non-text labels.
|
|
19
|
+
*
|
|
20
|
+
* Props:
|
|
21
|
+
* value — current option value
|
|
22
|
+
* onChange — handler (newValue) => void
|
|
23
|
+
* options — [{ value, label, ariaLabel? }]
|
|
24
|
+
* size — 'md' (default, 26px) | 'sm' (16px). `sm` is for icon/preview-
|
|
25
|
+
* only options (e.g. line-style previews) where text labels
|
|
26
|
+
* aren't used; the cells still center their contents but the
|
|
27
|
+
* outer height is tighter.
|
|
28
|
+
* className — additional classes on the outer shell
|
|
29
|
+
*/
|
|
30
|
+
export default function SegmentedToggle({ value, onChange, options = [], size = 'md', className = '' }) {
|
|
31
|
+
const wrapHeight = size === 'sm' ? 'h-4' : 'h-[26px]' /* 16 vs 26 */
|
|
32
|
+
const cellType = size === 'sm' ? '' : 'kol-mono-12'
|
|
33
|
+
return (
|
|
34
|
+
<div className={`flex ${wrapHeight} border border-fg-04 rounded overflow-hidden ${className}`}>
|
|
35
|
+
{options.map((opt, i) => {
|
|
36
|
+
const isActive = opt.value === value
|
|
37
|
+
return (
|
|
38
|
+
<button
|
|
39
|
+
key={opt.value}
|
|
40
|
+
type="button"
|
|
41
|
+
onClick={() => onChange?.(opt.value)}
|
|
42
|
+
aria-pressed={isActive}
|
|
43
|
+
aria-label={opt.ariaLabel}
|
|
44
|
+
className={[
|
|
45
|
+
`flex-1 ${cellType} inline-flex items-center justify-center cursor-pointer`,
|
|
46
|
+
isActive ? 'bg-surface-secondary text-emphasis' : 'text-meta hover:text-emphasis',
|
|
47
|
+
i > 0 ? 'border-l border-fg-04' : '',
|
|
48
|
+
].filter(Boolean).join(' ')}
|
|
49
|
+
>
|
|
50
|
+
{opt.label}
|
|
51
|
+
</button>
|
|
52
|
+
)
|
|
53
|
+
})}
|
|
54
|
+
</div>
|
|
55
|
+
)
|
|
56
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { Icon } from '@kolkrabbi/kol-loader'
|
|
2
|
+
|
|
3
|
+
const ICON_SIZES = { sm: 10, md: 12, lg: 14 }
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Tag — canonical (merged web rich + brand compat).
|
|
7
|
+
*
|
|
8
|
+
* Web's rich API: variant (default/naked/inverse/solid), size, color, solid,
|
|
9
|
+
* active, icon, onRemove, onClick. Plus:
|
|
10
|
+
* - `hash` (default true) — prepend `#` (web's tag style). Pass hash={false}
|
|
11
|
+
* for plain labels (brand usage).
|
|
12
|
+
* - `text` — content fallback when no children (brand SwatchControls passes text=).
|
|
13
|
+
*/
|
|
14
|
+
export default function Tag({
|
|
15
|
+
children,
|
|
16
|
+
text,
|
|
17
|
+
variant = 'default',
|
|
18
|
+
size = 'md',
|
|
19
|
+
color,
|
|
20
|
+
solid = false,
|
|
21
|
+
active = false,
|
|
22
|
+
hash = true,
|
|
23
|
+
icon,
|
|
24
|
+
onRemove,
|
|
25
|
+
onClick,
|
|
26
|
+
className = ''
|
|
27
|
+
}) {
|
|
28
|
+
const isInteractive = !!(onClick || onRemove)
|
|
29
|
+
const Element = isInteractive ? 'button' : 'span'
|
|
30
|
+
const iconSize = ICON_SIZES[size] || 12
|
|
31
|
+
const content = children ?? text
|
|
32
|
+
|
|
33
|
+
let baseClass
|
|
34
|
+
if (variant === 'naked') {
|
|
35
|
+
baseClass = color ? `tag-naked tag--${color}` : 'tag-naked'
|
|
36
|
+
} else if (color) {
|
|
37
|
+
baseClass = `tag tag--${color}`
|
|
38
|
+
} else {
|
|
39
|
+
baseClass = variant === 'inverse' ? 'tag-control-inverse' : 'tag-control'
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const isSolid = solid || variant === 'solid'
|
|
43
|
+
const activeClass = active ? (color ? 'tag--active' : 'is-active') : ''
|
|
44
|
+
|
|
45
|
+
const classes = [
|
|
46
|
+
baseClass,
|
|
47
|
+
`tag-${size}`,
|
|
48
|
+
isSolid && variant !== 'naked' ? 'tag--solid' : '',
|
|
49
|
+
activeClass,
|
|
50
|
+
isInteractive ? 'cursor-pointer' : '',
|
|
51
|
+
className
|
|
52
|
+
].filter(Boolean).join(' ')
|
|
53
|
+
|
|
54
|
+
const handleRemove = (e) => {
|
|
55
|
+
e.stopPropagation()
|
|
56
|
+
onRemove?.(e)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<Element
|
|
61
|
+
type={isInteractive ? 'button' : undefined}
|
|
62
|
+
className={classes}
|
|
63
|
+
onClick={onClick}
|
|
64
|
+
>
|
|
65
|
+
{icon && <Icon name={icon} size={iconSize} />}
|
|
66
|
+
<span>{hash ? '#' : ''}{content}</span>
|
|
67
|
+
{onRemove && (
|
|
68
|
+
<span
|
|
69
|
+
role="button"
|
|
70
|
+
tabIndex={-1}
|
|
71
|
+
className="tag-dismiss"
|
|
72
|
+
onClick={handleRemove}
|
|
73
|
+
>
|
|
74
|
+
<Icon name="cross" size={iconSize} />
|
|
75
|
+
</span>
|
|
76
|
+
)}
|
|
77
|
+
</Element>
|
|
78
|
+
)
|
|
79
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ToggleBracket — text-pair toggle showing `Label [STATE]`.
|
|
3
|
+
*
|
|
4
|
+
* variant="default" — Filled shell (bg-surface-secondary). When ON,
|
|
5
|
+
* overrides to accent-yellow with navy ink.
|
|
6
|
+
* variant="plain" — Bare text rows, no chrome. For inline contexts
|
|
7
|
+
* (sidenav child rows, dense panels).
|
|
8
|
+
*/
|
|
9
|
+
const ToggleBracket = ({
|
|
10
|
+
label,
|
|
11
|
+
value = false,
|
|
12
|
+
onToggle,
|
|
13
|
+
onChange,
|
|
14
|
+
offLabel = 'OFF',
|
|
15
|
+
onLabel = 'ON',
|
|
16
|
+
variant = 'default',
|
|
17
|
+
className = '',
|
|
18
|
+
...props
|
|
19
|
+
}) => {
|
|
20
|
+
const handleClick = () => {
|
|
21
|
+
if (onToggle) onToggle(!value)
|
|
22
|
+
if (onChange) onChange(!value)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const isPlain = variant === 'plain'
|
|
26
|
+
|
|
27
|
+
const cls = isPlain
|
|
28
|
+
? `inline-flex items-center gap-3 kol-mono-12 uppercase text-meta hover:text-emphasis transition-colors bg-transparent border-0 p-0 cursor-pointer ${className}`
|
|
29
|
+
: `kol-control kol-control--filled kol-control-md kol-mono-12 uppercase justify-between gap-3 ${value ? 'toggle-bracket--active' : ''} ${className}`
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<button
|
|
33
|
+
type="button"
|
|
34
|
+
className={cls.trim()}
|
|
35
|
+
onClick={handleClick}
|
|
36
|
+
aria-pressed={value}
|
|
37
|
+
{...props}
|
|
38
|
+
>
|
|
39
|
+
<span>{label}</span>
|
|
40
|
+
<span>[{value ? onLabel : offLabel}]</span>
|
|
41
|
+
</button>
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export default ToggleBracket
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { Icon } from '@kolkrabbi/kol-loader'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ViewToggle — control for switching between view modes.
|
|
5
|
+
*
|
|
6
|
+
* variant="text" — segmented bare buttons; active uses kol-control--filled,
|
|
7
|
+
* inactive is bare text-meta with text-emphasis on hover.
|
|
8
|
+
* variant="icon" — bordered chip-row container holding square icon
|
|
9
|
+
* buttons. Active uses bg-fg-absolute-24.
|
|
10
|
+
* variant="single" — single button binary toggle. Click flips between the
|
|
11
|
+
* two `options` values. The button shows the *current*
|
|
12
|
+
* option's label; active = on (kol-control--filled),
|
|
13
|
+
* inactive = off (bare text). Use for compact on/off
|
|
14
|
+
* where a segmented two-button toggle is overkill.
|
|
15
|
+
*
|
|
16
|
+
* Built on the .kol-control shell. Default options use grid-06 / list-01
|
|
17
|
+
* icons; consumers can pass `options` to override. For `variant="single"`,
|
|
18
|
+
* the FIRST option in `options` is the "off" value; the SECOND is "on".
|
|
19
|
+
*/
|
|
20
|
+
const ViewToggle = ({
|
|
21
|
+
viewMode,
|
|
22
|
+
onViewChange,
|
|
23
|
+
variant = 'text',
|
|
24
|
+
options = [
|
|
25
|
+
{ value: 'grid', label: 'Grid view', icon: 'grid-06' },
|
|
26
|
+
{ value: 'list', label: 'List view', icon: 'list-01' }
|
|
27
|
+
],
|
|
28
|
+
className = ''
|
|
29
|
+
}) => {
|
|
30
|
+
const isIconVariant = variant === 'icon'
|
|
31
|
+
const isSingleVariant = variant === 'single'
|
|
32
|
+
|
|
33
|
+
if (isSingleVariant) {
|
|
34
|
+
const [offOpt, onOpt] = options
|
|
35
|
+
const isOn = viewMode === onOpt.value
|
|
36
|
+
const next = isOn ? offOpt.value : onOpt.value
|
|
37
|
+
const cls = isOn
|
|
38
|
+
? `kol-control kol-control--filled kol-control-sm kol-mono-12 ${className}`
|
|
39
|
+
: `kol-control kol-control-sm kol-mono-12 text-meta hover:text-emphasis ${className}`
|
|
40
|
+
/* Both labels stack in a single grid cell so the button width is fixed
|
|
41
|
+
* to the longer label — flipping state never reflows the row. */
|
|
42
|
+
return (
|
|
43
|
+
<button
|
|
44
|
+
type="button"
|
|
45
|
+
onClick={() => onViewChange(next)}
|
|
46
|
+
className={cls}
|
|
47
|
+
aria-pressed={isOn}
|
|
48
|
+
title={isOn ? onOpt.label : offOpt.label}
|
|
49
|
+
>
|
|
50
|
+
<span className="grid">
|
|
51
|
+
<span className={`col-start-1 row-start-1 ${isOn ? '' : 'invisible'}`}>{onOpt.label}</span>
|
|
52
|
+
<span className={`col-start-1 row-start-1 ${isOn ? 'invisible' : ''}`}>{offOpt.label}</span>
|
|
53
|
+
</span>
|
|
54
|
+
</button>
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const containerClasses = isIconVariant
|
|
59
|
+
? `inline-flex items-center gap-1 p-1 bg-surface-secondary rounded ${className}`
|
|
60
|
+
: `flex gap-2 ${className}`
|
|
61
|
+
|
|
62
|
+
const buttonClasses = (isActive) => {
|
|
63
|
+
if (isIconVariant) {
|
|
64
|
+
// Inset "well" pattern — container is bg-fg-04 (slight lift from page),
|
|
65
|
+
// active button uses bg-fg-absolute-24 (theme-invariant black, always
|
|
66
|
+
// darkens regardless of theme) so it reads as recessed/pressed.
|
|
67
|
+
return `inline-flex items-center justify-center p-1.5 rounded transition-colors text-emphasis cursor-pointer ${
|
|
68
|
+
isActive ? 'bg-fg-absolute-24' : 'hover:bg-fg-absolute-08'
|
|
69
|
+
}`
|
|
70
|
+
}
|
|
71
|
+
/* Active = filled chip. Inactive = bare-text on the shell base — no
|
|
72
|
+
* border-reveal hover. (Earlier ghost variant revealed an outline on
|
|
73
|
+
* hover; that's deliberately gone.) */
|
|
74
|
+
return isActive
|
|
75
|
+
? 'kol-control kol-control--filled kol-control-sm kol-mono-12'
|
|
76
|
+
: 'kol-control kol-control-sm kol-mono-12 text-meta hover:text-emphasis'
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<div className={containerClasses}>
|
|
81
|
+
{options.map((option) => (
|
|
82
|
+
<button
|
|
83
|
+
key={option.value}
|
|
84
|
+
onClick={() => onViewChange(option.value)}
|
|
85
|
+
className={buttonClasses(viewMode === option.value)}
|
|
86
|
+
aria-label={option.label}
|
|
87
|
+
aria-pressed={viewMode === option.value}
|
|
88
|
+
title={option.label}
|
|
89
|
+
>
|
|
90
|
+
{isIconVariant && option.icon ? (
|
|
91
|
+
<Icon name={option.icon} size={14} />
|
|
92
|
+
) : (
|
|
93
|
+
option.label
|
|
94
|
+
)}
|
|
95
|
+
</button>
|
|
96
|
+
))}
|
|
97
|
+
</div>
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export default ViewToggle
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Table — data table.
|
|
3
|
+
* Styles: src/styles/kol-components-organisms.css.
|
|
4
|
+
*
|
|
5
|
+
* Variants:
|
|
6
|
+
* default — bordered, column dividers, header bg
|
|
7
|
+
* simple — borderless, flush, no column dividers
|
|
8
|
+
*/
|
|
9
|
+
const Table = ({ caption, columns, rows, variant = 'default', className = '' }) => {
|
|
10
|
+
const variantClass = variant === 'simple' ? 'kol-table--simple' : ''
|
|
11
|
+
const wrapperClass = ['kol-table-wrapper', variantClass, className].filter(Boolean).join(' ')
|
|
12
|
+
return (
|
|
13
|
+
<div className={wrapperClass}>
|
|
14
|
+
<table className="kol-table">
|
|
15
|
+
{caption ? <caption className="sr-only">{caption}</caption> : null}
|
|
16
|
+
<thead className="kol-table-thead">
|
|
17
|
+
<tr>
|
|
18
|
+
{columns.map((column) => (
|
|
19
|
+
<th
|
|
20
|
+
key={column.accessor}
|
|
21
|
+
scope="col"
|
|
22
|
+
className={column.headerClassName ?? 'kol-table-cell-title'}
|
|
23
|
+
style={column.style}
|
|
24
|
+
>
|
|
25
|
+
{column.header}
|
|
26
|
+
</th>
|
|
27
|
+
))}
|
|
28
|
+
</tr>
|
|
29
|
+
</thead>
|
|
30
|
+
<tbody>
|
|
31
|
+
{rows.map((row, rowIndex) => (
|
|
32
|
+
<tr key={row.id ?? row.token ?? rowIndex} className="kol-table-row">
|
|
33
|
+
{columns.map((column) => (
|
|
34
|
+
<td key={column.accessor} className={(typeof column.className === 'function' ? column.className(row) : column.className) ?? 'kol-table-cell-text'} style={column.style}>
|
|
35
|
+
{column.render ? column.render(row) : row[column.accessor] ?? '—'}
|
|
36
|
+
</td>
|
|
37
|
+
))}
|
|
38
|
+
</tr>
|
|
39
|
+
))}
|
|
40
|
+
</tbody>
|
|
41
|
+
</table>
|
|
42
|
+
</div>
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export default Table
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Accordion — collapsible panel group.
|
|
3
|
+
*
|
|
4
|
+
* Composition-based: <Accordion> wraps <AccordionPanel> children. Each panel
|
|
5
|
+
* owns its open/closed state independently (additive — multiple can be open).
|
|
6
|
+
* For single-open behavior, manage state from the parent and pass controlled
|
|
7
|
+
* `open` + `onToggle` props to each panel.
|
|
8
|
+
*/
|
|
9
|
+
import { useState } from 'react'
|
|
10
|
+
|
|
11
|
+
export function Accordion({ children, className = '' }) {
|
|
12
|
+
return <div className={`kol-accordion mt-2 mb-6 ${className}`.trim()}>{children}</div>
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function AccordionPanel({
|
|
16
|
+
title,
|
|
17
|
+
meta,
|
|
18
|
+
defaultOpen = false,
|
|
19
|
+
open: controlledOpen,
|
|
20
|
+
onToggle,
|
|
21
|
+
children,
|
|
22
|
+
}) {
|
|
23
|
+
const [internalOpen, setInternalOpen] = useState(defaultOpen)
|
|
24
|
+
const isControlled = controlledOpen !== undefined
|
|
25
|
+
const open = isControlled ? controlledOpen : internalOpen
|
|
26
|
+
const toggle = () => {
|
|
27
|
+
if (isControlled) onToggle?.(!open)
|
|
28
|
+
else setInternalOpen(!open)
|
|
29
|
+
}
|
|
30
|
+
return (
|
|
31
|
+
<div className={`kol-accordion-panel border-t border-[var(--kol-fg-08)] last:border-b${open ? ' is-open' : ''}`}>
|
|
32
|
+
<button
|
|
33
|
+
type="button"
|
|
34
|
+
className="kol-accordion-trigger flex items-center gap-4 w-full py-4 bg-transparent border-0 cursor-pointer text-left font-mono text-emphasis transition-colors duration-[120ms] hover:text-fg-88"
|
|
35
|
+
onClick={toggle}
|
|
36
|
+
aria-expanded={open}
|
|
37
|
+
>
|
|
38
|
+
<span className="kol-accordion-title kol-helper-12 uppercase tracking-widest">{title}</span>
|
|
39
|
+
{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 font-mono text-[18px] text-fg-48 min-w-3 transition-colors duration-[120ms] ml-auto" aria-hidden="true">{open ? '−' : '+'}</span>
|
|
41
|
+
</button>
|
|
42
|
+
{open && <div className="kol-accordion-body pt-2 pb-6">{children}</div>}
|
|
43
|
+
</div>
|
|
44
|
+
)
|
|
45
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AssetPlaceholder — deliberate "missing asset" frame.
|
|
3
|
+
*
|
|
4
|
+
* Used by Image + Graphic as fallback when the resource can't be found.
|
|
5
|
+
* Renders an outlined tile with the asset's category/name so missing assets
|
|
6
|
+
* are visibly flagged rather than showing the browser's broken-image icon.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export default function AssetPlaceholder({
|
|
10
|
+
category,
|
|
11
|
+
name,
|
|
12
|
+
aspectRatio = '16 / 9',
|
|
13
|
+
note = 'missing',
|
|
14
|
+
className = '',
|
|
15
|
+
}) {
|
|
16
|
+
const label = [category, name].filter(Boolean).join(' · ')
|
|
17
|
+
return (
|
|
18
|
+
<div
|
|
19
|
+
className={`kol-asset-placeholder flex flex-col items-center justify-center gap-[6px] w-full p-6 border border-dashed border-[var(--kol-fg-24)] rounded-[4px] bg-[var(--kol-fg-02)] text-fg-48 font-mono text-center box-border ${className}`.trim()}
|
|
20
|
+
style={{ aspectRatio }}
|
|
21
|
+
role="img"
|
|
22
|
+
aria-label={`${label || 'asset'} — ${note}`}
|
|
23
|
+
>
|
|
24
|
+
<span className="kol-asset-placeholder-note text-[12px] uppercase [letter-spacing:0.12em] text-fg-48">{note}</span>
|
|
25
|
+
{label && <span className="kol-asset-placeholder-label text-[12px] text-fg-40 [letter-spacing:0.04em]">{label}</span>}
|
|
26
|
+
</div>
|
|
27
|
+
)
|
|
28
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { Children, useCallback, useEffect, useState } from 'react'
|
|
2
|
+
import useEmblaCarousel from 'embla-carousel-react'
|
|
3
|
+
|
|
4
|
+
export default function Carousel({ children, options = { align: 'start', loop: false, dragFree: true, containScroll: 'trimSnaps' }, className = '' }) {
|
|
5
|
+
const [emblaRef, emblaApi] = useEmblaCarousel(options)
|
|
6
|
+
const [canPrev, setCanPrev] = useState(false)
|
|
7
|
+
const [canNext, setCanNext] = useState(false)
|
|
8
|
+
|
|
9
|
+
const onSelect = useCallback((api) => {
|
|
10
|
+
setCanPrev(api.canScrollPrev())
|
|
11
|
+
setCanNext(api.canScrollNext())
|
|
12
|
+
}, [])
|
|
13
|
+
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
if (!emblaApi) return
|
|
16
|
+
onSelect(emblaApi)
|
|
17
|
+
emblaApi.on('select', onSelect)
|
|
18
|
+
emblaApi.on('reInit', onSelect)
|
|
19
|
+
}, [emblaApi, onSelect])
|
|
20
|
+
|
|
21
|
+
const slides = Children.toArray(children)
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<div className={`kol-embla ${className}`.trim()}>
|
|
25
|
+
<div className="kol-embla-viewport" ref={emblaRef}>
|
|
26
|
+
<div className="kol-embla-container">
|
|
27
|
+
{slides.map((child, i) => (
|
|
28
|
+
<div key={i} className="kol-embla-slide">{child}</div>
|
|
29
|
+
))}
|
|
30
|
+
</div>
|
|
31
|
+
</div>
|
|
32
|
+
<div className="kol-embla-controls">
|
|
33
|
+
<button
|
|
34
|
+
type="button"
|
|
35
|
+
className="kol-embla-btn border border-fg-16 hover:border-fg-32 text-auto"
|
|
36
|
+
aria-label="Previous"
|
|
37
|
+
onClick={() => emblaApi?.scrollPrev()}
|
|
38
|
+
disabled={!canPrev}
|
|
39
|
+
>‹</button>
|
|
40
|
+
<button
|
|
41
|
+
type="button"
|
|
42
|
+
className="kol-embla-btn border border-fg-16 hover:border-fg-32 text-auto"
|
|
43
|
+
aria-label="Next"
|
|
44
|
+
onClick={() => emblaApi?.scrollNext()}
|
|
45
|
+
disabled={!canNext}
|
|
46
|
+
>›</button>
|
|
47
|
+
</div>
|
|
48
|
+
</div>
|
|
49
|
+
)
|
|
50
|
+
}
|