@kolkrabbi/kol-component 0.7.0 → 0.8.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 +8 -0
- package/package.json +2 -5
- package/src/atoms/RotaryDial.jsx +35 -21
- package/src/index.js +6 -18
- package/src/molecules/ButtonGroup.jsx +45 -0
- package/src/molecules/ColorInputRow.jsx +146 -126
- package/src/molecules/Slider.jsx +29 -14
- package/src/molecules/SplitToolButton.jsx +133 -0
- package/src/organisms/ContentFilters.jsx +2 -2
- package/src/organisms/GalleryCarousel.jsx +2 -1
- package/src/organisms/LoaderOverlay.jsx +11 -14
- package/src/organisms/MediaTileGallery.jsx +57 -0
- package/src/organisms/MediaViewer.jsx +89 -64
- package/src/atoms/PriceDisplay.jsx +0 -34
- package/src/atoms/TextPressure.jsx +0 -331
- package/src/atoms/TypeSample.jsx +0 -50
- package/src/atoms/TypeSpecCard.jsx +0 -42
- package/src/chess/ChessPiece.jsx +0 -191
- package/src/chess/apparatus/AlternativeControlsMock.jsx +0 -460
- package/src/chess/apparatus/ChessAnalysisLayout.jsx +0 -24
- package/src/chess/apparatus/ChessBoard.jsx +0 -210
- package/src/chess/apparatus/ChessBoardFullscreen.jsx +0 -49
- package/src/chess/apparatus/ChessBoardWithControls.jsx +0 -41
- package/src/chess/apparatus/ChessBoardWithSidebar.jsx +0 -122
- package/src/chess/apparatus/ChessSidebar.jsx +0 -131
- package/src/chess/apparatus/GameArchiveTable.jsx +0 -493
- package/src/chess/apparatus/GameSelector.jsx +0 -47
- package/src/chess/apparatus/NotationPanel.jsx +0 -70
- package/src/chess/apparatus/PlaybackControls.jsx +0 -52
- package/src/chess/apparatus/VariationTree.jsx +0 -137
- package/src/chess/assets/chess-extra-set/set-1/bishop-1.svg +0 -6
- package/src/chess/assets/chess-extra-set/set-1/bishop.svg +0 -6
- package/src/chess/assets/chess-extra-set/set-1/king-1.svg +0 -9
- package/src/chess/assets/chess-extra-set/set-1/king.svg +0 -7
- package/src/chess/assets/chess-extra-set/set-1/knight-1.svg +0 -8
- package/src/chess/assets/chess-extra-set/set-1/knight.svg +0 -7
- package/src/chess/assets/chess-extra-set/set-1/pawn-1.svg +0 -3
- package/src/chess/assets/chess-extra-set/set-1/pawn.svg +0 -3
- package/src/chess/assets/chess-extra-set/set-1/queen-1.svg +0 -11
- package/src/chess/assets/chess-extra-set/set-1/queen.svg +0 -6
- package/src/chess/assets/chess-extra-set/set-1/rook-1.svg +0 -7
- package/src/chess/assets/chess-extra-set/set-1/rook.svg +0 -11
- package/src/chess/assets/chess-extra-set/set-2/bishop-1.svg +0 -4
- package/src/chess/assets/chess-extra-set/set-2/bishop.svg +0 -4
- package/src/chess/assets/chess-extra-set/set-2/king-1.svg +0 -4
- package/src/chess/assets/chess-extra-set/set-2/king.svg +0 -4
- package/src/chess/assets/chess-extra-set/set-2/knight-1.svg +0 -5
- package/src/chess/assets/chess-extra-set/set-2/knight.svg +0 -5
- package/src/chess/assets/chess-extra-set/set-2/pawn-1.svg +0 -4
- package/src/chess/assets/chess-extra-set/set-2/pawn.svg +0 -4
- package/src/chess/assets/chess-extra-set/set-2/queen-1.svg +0 -4
- package/src/chess/assets/chess-extra-set/set-2/queen.svg +0 -4
- package/src/chess/assets/chess-extra-set/set-2/rook-1.svg +0 -5
- package/src/chess/assets/chess-extra-set/set-2/rook.svg +0 -5
- package/src/chess/assets/chess-extra-set/set-3/bishop-1.svg +0 -10
- package/src/chess/assets/chess-extra-set/set-3/bishop.svg +0 -3
- package/src/chess/assets/chess-extra-set/set-3/king-1.svg +0 -12
- package/src/chess/assets/chess-extra-set/set-3/king.svg +0 -3
- package/src/chess/assets/chess-extra-set/set-3/knight-1.svg +0 -3
- package/src/chess/assets/chess-extra-set/set-3/knight.svg +0 -10
- package/src/chess/assets/chess-extra-set/set-3/pawn-1.svg +0 -10
- package/src/chess/assets/chess-extra-set/set-3/pawn.svg +0 -10
- package/src/chess/assets/chess-extra-set/set-3/queen-1.svg +0 -10
- package/src/chess/assets/chess-extra-set/set-3/queen.svg +0 -3
- package/src/chess/assets/chess-extra-set/set-3/rook-1.svg +0 -3
- package/src/chess/assets/chess-extra-set/set-3/rook.svg +0 -11
- package/src/chess/assets/chess-vector-set/ABoard.svg +0 -164
- package/src/chess/assets/chess-vector-set/BBiskup.svg +0 -12
- package/src/chess/assets/chess-vector-set/BKing.svg +0 -15
- package/src/chess/assets/chess-vector-set/BKnight.svg +0 -13
- package/src/chess/assets/chess-vector-set/BPawn.svg +0 -7
- package/src/chess/assets/chess-vector-set/BQueen.svg +0 -6
- package/src/chess/assets/chess-vector-set/BRook.svg +0 -12
- package/src/chess/assets/chess-vector-set/WBiskup.svg +0 -14
- package/src/chess/assets/chess-vector-set/WKing.svg +0 -13
- package/src/chess/assets/chess-vector-set/WKnight.svg +0 -10
- package/src/chess/assets/chess-vector-set/WPawn.svg +0 -8
- package/src/chess/assets/chess-vector-set/WQueen.svg +0 -17
- package/src/chess/assets/chess-vector-set/WRook.svg +0 -13
- package/src/chess/context/ChessControlsContext.jsx +0 -379
- package/src/chess/dashboards/ChessHero.jsx +0 -303
- package/src/chess/index.js +0 -32
- package/src/chess/utils/parsePgnTree.js +0 -97
- package/src/dashboards/cards/DashAlertCard.jsx +0 -44
- package/src/dashboards/cards/DashChartCard.jsx +0 -44
- package/src/dashboards/cards/DashFeaturedCard.jsx +0 -42
- package/src/dashboards/cards/DashListCard.jsx +0 -87
- package/src/dashboards/cards/DashMetricCard.jsx +0 -59
- package/src/dashboards/cards/DashSlotCard.jsx +0 -45
- package/src/dashboards/cards/DashStackedBarCard.jsx +0 -70
- package/src/dashboards/cards/DashTableCard.jsx +0 -31
- package/src/dashboards/cards/_shared/CardHeader.jsx +0 -15
- package/src/dashboards/cards/_shared/LegendRow.jsx +0 -25
- package/src/dashboards/charts/Candlestick.jsx +0 -79
- package/src/dashboards/charts/DonutChart.jsx +0 -87
- package/src/dashboards/charts/Heatmap.jsx +0 -79
- package/src/dashboards/charts/Histogram.jsx +0 -56
- package/src/dashboards/charts/LineChart.jsx +0 -157
- package/src/dashboards/charts/ScatterPlot.jsx +0 -93
- package/src/dashboards/charts/Sparkline.jsx +0 -63
- package/src/dashboards/index.js +0 -28
- package/src/dashboards/layout/DashboardGrid.jsx +0 -30
- package/src/dashboards/layout/GridCard.jsx +0 -44
- package/src/dashboards/shared/DashTooltip.jsx +0 -6
- package/src/dashboards/shared/formatMetric.js +0 -26
- package/src/dashboards/shared/useChartTooltip.js +0 -25
- package/src/dashboards/shared/useCountUp.js +0 -107
- package/src/molecules/ArticleCard.jsx +0 -178
- package/src/molecules/WorkListItem.jsx +0 -83
- package/src/molecules/foundry/SpecimenSectionHeader.jsx +0 -89
- package/src/organisms/ArticleHeader.jsx +0 -95
- package/src/organisms/ColorLoader.jsx +0 -155
- package/src/organisms/DiagonalMarqueeRiver.jsx +0 -138
- package/src/organisms/ParallaxShelf.jsx +0 -141
- package/src/organisms/PortableTextRenderer.jsx +0 -115
- package/src/organisms/ProductDetailLayout.jsx +0 -189
- package/src/organisms/ScrollDriftGallery.jsx +0 -214
- package/src/organisms/StackHero.jsx +0 -83
- package/src/organisms/WorkCard.jsx +0 -120
- package/src/organisms/WorkViewToggle.jsx +0 -170
- package/src/organisms/foundry/FontPreviewSection.jsx +0 -187
- package/src/organisms/foundry/FoundryCharacterSets.jsx +0 -113
- package/src/organisms/foundry/GlyphMetricsGrid.jsx +0 -335
- package/src/organisms/foundry/TypefaceHero.jsx +0 -107
- package/src/organisms/foundry/TypefaceStyleSection.jsx +0 -163
- package/src/organisms/foundry/VariableFontSection.jsx +0 -158
- package/src/organisms/foundry/glyphData.js +0 -30
- package/src/organisms/foundry/index.js +0 -21
package/src/molecules/Slider.jsx
CHANGED
|
@@ -2,28 +2,35 @@ import { useEffect, useMemo, useState } from 'react'
|
|
|
2
2
|
import Input from '../atoms/Input.jsx'
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* Slider — range slider with label and an editable value readout.
|
|
5
|
+
* Slider — range slider with label and an editable value readout. The LINEAR
|
|
6
|
+
* VARIANT of RotaryDial (atoms/RotaryDial.jsx): both implement the shared
|
|
7
|
+
* value-control contract — `value` / `min` / `max` / `step` /
|
|
8
|
+
* `onChange(next: number)` / `label` / `size` / `disabled` / `formatValue`.
|
|
9
|
+
* Controlled; onChange always fires with the plain number.
|
|
6
10
|
*
|
|
7
11
|
* One bare inline row: label · track · editable readout. (Bordered
|
|
8
12
|
* `default` and chip `subtle` variants retired 2026-07-08 — minimal is
|
|
9
13
|
* the only slider.)
|
|
10
14
|
*
|
|
11
15
|
* The value readout is an editable <Input> (type a value, commit on
|
|
12
|
-
* blur / Enter, revert on Escape)
|
|
13
|
-
*
|
|
14
|
-
*
|
|
16
|
+
* blur / Enter, revert on Escape) — a Slider-only extra; RotaryDial's
|
|
17
|
+
* readout is display-only (atoms nest no KOL component). Track color is
|
|
18
|
+
* exposed as the `--kol-slider-track` CSS variable on `.slider-black`;
|
|
19
|
+
* override per-instance via style={{ '--kol-slider-track': '...' }}.
|
|
15
20
|
*
|
|
16
21
|
* @param {Object} props
|
|
17
22
|
* @param {string} props.label - Slider label text
|
|
18
|
-
* @param {number} props.min - Minimum value
|
|
19
|
-
* @param {number} props.max - Maximum value
|
|
20
|
-
* @param {number} props.value -
|
|
21
|
-
* @param {Function} props.onChange -
|
|
23
|
+
* @param {number} props.min - Minimum value (default: 0)
|
|
24
|
+
* @param {number} props.max - Maximum value (default: 100)
|
|
25
|
+
* @param {number} props.value - Controlled value within min–max
|
|
26
|
+
* @param {Function} props.onChange - (next: number) => void change handler
|
|
27
|
+
* @param {number} props.step - Slider step increment (default: 1)
|
|
28
|
+
* @param {number} props.size - Track length in px (mirrors RotaryDial's dial px size); unset = fluid flex-1 track
|
|
29
|
+
* @param {boolean} props.disabled - Disables track + readout and dims the control (default: false)
|
|
30
|
+
* @param {Function} props.formatValue - Optional formatter for displayed value; RotaryDial's default readout is `${value}%`
|
|
22
31
|
* @param {string} props.className - Additional wrapper classes
|
|
23
32
|
* @param {number} props.displayWidth - Width of the value readout, in characters (default: 6)
|
|
24
33
|
* @param {string} props.fontSize - Font size for label/value (e.g., '11px')
|
|
25
|
-
* @param {number} props.step - Slider step increment (default: 1)
|
|
26
|
-
* @param {Function} props.formatValue - Optional formatter for displayed value
|
|
27
34
|
*/
|
|
28
35
|
const Slider = ({
|
|
29
36
|
label,
|
|
@@ -31,11 +38,13 @@ const Slider = ({
|
|
|
31
38
|
max = 100,
|
|
32
39
|
value = 0,
|
|
33
40
|
onChange,
|
|
41
|
+
step = 1,
|
|
42
|
+
size,
|
|
43
|
+
disabled = false,
|
|
44
|
+
formatValue,
|
|
34
45
|
className = '',
|
|
35
46
|
displayWidth = 6,
|
|
36
47
|
fontSize,
|
|
37
|
-
step = 1,
|
|
38
|
-
formatValue
|
|
39
48
|
}) => {
|
|
40
49
|
const handleChange = (e) => {
|
|
41
50
|
if (onChange) {
|
|
@@ -86,7 +95,10 @@ const Slider = ({
|
|
|
86
95
|
return (
|
|
87
96
|
<div className={`control-slider gap-3 shadow-none ${className}`}>
|
|
88
97
|
{label && (
|
|
89
|
-
<label
|
|
98
|
+
<label
|
|
99
|
+
className={`kol-helper-12 whitespace-nowrap shrink-0 w-fit ${disabled ? 'opacity-50' : ''}`}
|
|
100
|
+
style={fontSize ? { fontSize } : undefined}
|
|
101
|
+
>
|
|
90
102
|
{label}
|
|
91
103
|
</label>
|
|
92
104
|
)}
|
|
@@ -97,7 +109,9 @@ const Slider = ({
|
|
|
97
109
|
step={step}
|
|
98
110
|
value={value}
|
|
99
111
|
onChange={handleChange}
|
|
100
|
-
|
|
112
|
+
disabled={disabled}
|
|
113
|
+
className={`slider-black cursor-pointer disabled:cursor-default disabled:opacity-50 ${size ? 'flex-none' : 'flex-1 w-full'}`}
|
|
114
|
+
style={size ? { width: size } : undefined}
|
|
101
115
|
/>
|
|
102
116
|
<Input
|
|
103
117
|
type="text"
|
|
@@ -106,6 +120,7 @@ const Slider = ({
|
|
|
106
120
|
size="sm"
|
|
107
121
|
chars={displayWidth}
|
|
108
122
|
value={draft}
|
|
123
|
+
disabled={disabled}
|
|
109
124
|
onFocus={(e) => { setEditing(true); e.target.select() }}
|
|
110
125
|
onChange={(e) => setDraft(e.target.value)}
|
|
111
126
|
onBlur={commit}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import { Icon } from '@kolkrabbi/kol-icons'
|
|
3
|
+
import { PopoverPanel, usePopover } from '../atoms/Popover.jsx'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* SplitToolButton — single-trigger split tool button + variant menu (the
|
|
7
|
+
* tool-palette idiom: Select · Text · [Shape ◢] · Pattern). A 28×28
|
|
8
|
+
* quiet/pressed trigger shows the current variant while the group is `active`
|
|
9
|
+
* (else the `lastPicked` variant) plus a corner fold indicator; ONE click both
|
|
10
|
+
* arms that variant (`onChange`) and opens the variant menu (floating-ui's own
|
|
11
|
+
* click handling), so the user can immediately re-pick. Menu rows show ✓ on
|
|
12
|
+
* the active variant, else the variant's shortcut.
|
|
13
|
+
*
|
|
14
|
+
* The trigger is a plain `kol-btn` element, not DS Button: Button doesn't
|
|
15
|
+
* forward refs (the popover must anchor on the real button), can't host the
|
|
16
|
+
* corner fold inside its own box, and `blurOnClick` must blur the button
|
|
17
|
+
* itself. The class output — kol-btn kol-btn-ghost + kol-btn-quiet /
|
|
18
|
+
* kol-btn-pressed — is exactly what `<Button variant="ghost" quiet pressed>`
|
|
19
|
+
* emits, so the visual contract stays Button's.
|
|
20
|
+
*
|
|
21
|
+
* For a text-trigger single-select use `Dropdown`; for the two-button
|
|
22
|
+
* action-half + chevron-half split use `ShapeDropdown`.
|
|
23
|
+
*
|
|
24
|
+
* @param {Object} props
|
|
25
|
+
* @param {{id: string, label: string, icon: string, shortcut?: string}[]} props.variants - Variants: menu rows + trigger glyph
|
|
26
|
+
* @param {string} props.value - Active variant id (controlled)
|
|
27
|
+
* @param {Function} props.onChange - Fires with a variant id — on menu pick, and on trigger click while inactive (arming)
|
|
28
|
+
* @param {string} props.lastPicked - Variant id the trigger shows while the group is inactive (default: variants[0])
|
|
29
|
+
* @param {boolean} props.active - Whether this tool group is the active tool — lit trigger, `aria-pressed`
|
|
30
|
+
* @param {number} props.size - Trigger box in px (default: 28)
|
|
31
|
+
* @param {boolean} props.blurOnClick - Blur the trigger after click so a canvas can reclaim focus and refresh its cursor (default: false)
|
|
32
|
+
* @param {string} props.aria-label - Trigger label fallback when no variant resolves
|
|
33
|
+
* @param {string} props.className - Additional classes on the trigger
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/* Corner fold marker. `tool-fold-indicator` doesn't exist in kol-icons yet —
|
|
37
|
+
* icon-set candidate; inline until promoted. Lives inside the button so it
|
|
38
|
+
* dims with kol-btn-quiet and inverts with kol-btn-pressed (currentColor). */
|
|
39
|
+
const FoldIndicator = () => (
|
|
40
|
+
<svg
|
|
41
|
+
aria-hidden="true"
|
|
42
|
+
width={4}
|
|
43
|
+
height={4}
|
|
44
|
+
viewBox="0 0 4 4"
|
|
45
|
+
className="pointer-events-none absolute right-0.5 bottom-0.5 opacity-70"
|
|
46
|
+
>
|
|
47
|
+
<path d="M4 0v4H0z" fill="currentColor" />
|
|
48
|
+
</svg>
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
const SplitToolButton = ({
|
|
52
|
+
variants = [],
|
|
53
|
+
value,
|
|
54
|
+
onChange,
|
|
55
|
+
lastPicked,
|
|
56
|
+
active = false,
|
|
57
|
+
size = 28,
|
|
58
|
+
blurOnClick = false,
|
|
59
|
+
className = '',
|
|
60
|
+
'aria-label': ariaLabel,
|
|
61
|
+
}) => {
|
|
62
|
+
const [open, setOpen] = useState(false)
|
|
63
|
+
const popover = usePopover({
|
|
64
|
+
open,
|
|
65
|
+
onOpenChange: setOpen,
|
|
66
|
+
placement: 'bottom-start',
|
|
67
|
+
offset: 4,
|
|
68
|
+
role: 'menu',
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
const byId = (id) => variants.find((v) => v.id === id)
|
|
72
|
+
const triggerVariant = (active && byId(value)) || byId(lastPicked) || variants[0]
|
|
73
|
+
const label = triggerVariant?.label ?? ariaLabel
|
|
74
|
+
const title = triggerVariant?.shortcut ? `${label} (${triggerVariant.shortcut})` : label
|
|
75
|
+
|
|
76
|
+
/* Arm the last-picked variant on the same click that toggles the menu —
|
|
77
|
+
* floating-ui's useClick (on the reference) handles the open/close half. */
|
|
78
|
+
const handleTriggerClick = (event) => {
|
|
79
|
+
if (!active && triggerVariant) onChange?.(triggerVariant.id)
|
|
80
|
+
if (blurOnClick) event.currentTarget.blur()
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const handleSelect = (variant) => {
|
|
84
|
+
onChange?.(variant.id)
|
|
85
|
+
setOpen(false)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<>
|
|
90
|
+
<button
|
|
91
|
+
ref={popover.refs.setReference}
|
|
92
|
+
{...popover.getReferenceProps({ onClick: handleTriggerClick })}
|
|
93
|
+
type="button"
|
|
94
|
+
className={`relative kol-btn kol-btn-ghost ${active ? 'kol-btn-pressed' : 'kol-btn-quiet'} ${className}`.trim()}
|
|
95
|
+
style={{ width: size, height: size, padding: 6 }}
|
|
96
|
+
aria-pressed={active}
|
|
97
|
+
aria-label={label}
|
|
98
|
+
title={title}
|
|
99
|
+
>
|
|
100
|
+
{triggerVariant && <Icon name={triggerVariant.icon} size={14} />}
|
|
101
|
+
<FoldIndicator />
|
|
102
|
+
</button>
|
|
103
|
+
<PopoverPanel
|
|
104
|
+
popover={popover}
|
|
105
|
+
panel={false}
|
|
106
|
+
focus={false}
|
|
107
|
+
className="bg-surface-secondary border border-fg-08 rounded shadow-lg"
|
|
108
|
+
>
|
|
109
|
+
{variants.map((variant) => {
|
|
110
|
+
const isActive = active && variant.id === value
|
|
111
|
+
return (
|
|
112
|
+
<button
|
|
113
|
+
key={variant.id}
|
|
114
|
+
type="button"
|
|
115
|
+
onClick={() => handleSelect(variant)}
|
|
116
|
+
className="w-full kol-helper-12 px-3 h-8 inline-flex items-center gap-2 text-body hover:text-emphasis text-left"
|
|
117
|
+
>
|
|
118
|
+
<span className="shrink-0 w-4 inline-flex items-center justify-center">
|
|
119
|
+
<Icon name={variant.icon} size={14} />
|
|
120
|
+
</span>
|
|
121
|
+
<span className="flex-1 truncate">{variant.label}</span>
|
|
122
|
+
<span className="kol-helper-10 text-emphasis shrink-0">
|
|
123
|
+
{isActive ? '✓' : variant.shortcut}
|
|
124
|
+
</span>
|
|
125
|
+
</button>
|
|
126
|
+
)
|
|
127
|
+
})}
|
|
128
|
+
</PopoverPanel>
|
|
129
|
+
</>
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export default SplitToolButton
|
|
@@ -210,7 +210,7 @@ const ContentFilters = ({
|
|
|
210
210
|
key={opt.value}
|
|
211
211
|
onClick={() => handleViewModeChange(opt.value)}
|
|
212
212
|
className={`kol-helper-14 cursor-pointer select-none ${viewMode === opt.value ? 'text-fg-96' : 'text-fg-32 hover:text-fg-48'}`}
|
|
213
|
-
style={{
|
|
213
|
+
style={{ letterSpacing: 1 }}
|
|
214
214
|
>
|
|
215
215
|
{opt.label}
|
|
216
216
|
</span>
|
|
@@ -245,7 +245,7 @@ const ContentFilters = ({
|
|
|
245
245
|
key={opt.value}
|
|
246
246
|
onClick={() => setLayout(opt.value)}
|
|
247
247
|
className={`kol-helper-12 cursor-pointer select-none ${layout === opt.value ? 'text-fg-96' : 'text-fg-32 hover:text-fg-48'}`}
|
|
248
|
-
style={{
|
|
248
|
+
style={{ letterSpacing: 1 }}
|
|
249
249
|
>
|
|
250
250
|
{opt.label}
|
|
251
251
|
</span>
|
|
@@ -75,7 +75,8 @@ export default function GalleryCarousel({ media = [], title = '', defaultAspect
|
|
|
75
75
|
<MediaViewer
|
|
76
76
|
open={viewerIndex !== null}
|
|
77
77
|
media={media}
|
|
78
|
-
|
|
78
|
+
index={viewerIndex ?? 0}
|
|
79
|
+
onIndexChange={setViewerIndex}
|
|
79
80
|
onClose={() => setViewerIndex(null)}
|
|
80
81
|
/>
|
|
81
82
|
</section>
|
|
@@ -1,30 +1,27 @@
|
|
|
1
1
|
import FullscreenOverlay from '../atoms/FullscreenOverlay.jsx'
|
|
2
|
-
import ColorLoader from './ColorLoader.jsx'
|
|
3
2
|
|
|
4
3
|
/**
|
|
5
4
|
* LoaderOverlay — mounts a loading curtain over everything, dead center. A thin
|
|
6
5
|
* composition: it wraps DS FullscreenOverlay for the fixed, top-`z`,
|
|
7
6
|
* scroll-locking backdrop (replacing the app's hand-rolled `fixed inset-0 …
|
|
8
|
-
* z-[100]` div and its `CursorProvider`), then renders `children` — or
|
|
9
|
-
*
|
|
7
|
+
* z-[100]` div and its `CursorProvider`), then renders `children` — or the
|
|
8
|
+
* `loader` slot — as the overlay content. Being mounted IS being visible;
|
|
10
9
|
* the parent removes this element to dismiss.
|
|
11
10
|
*
|
|
12
|
-
* The
|
|
13
|
-
*
|
|
11
|
+
* The loader is a SLOT, not a built-in: inject a curtain (e.g. `<ColorLoader/>`
|
|
12
|
+
* from `@kolkrabbi/kol-foundry`) via `loader` and wire its completion callback
|
|
13
|
+
* yourself. The slot is mounted in a `fixed inset-0` box that escapes
|
|
14
14
|
* FullscreenOverlay's centered, `--kol-container-max`-width sheet so the loader
|
|
15
|
-
* fills the screen. Pass `children` to
|
|
15
|
+
* fills the screen. Pass `children` instead to mount anything else, centered.
|
|
16
|
+
* With neither, the loader slot renders nothing (the overlay still works).
|
|
16
17
|
*
|
|
17
|
-
* @param {ReactNode}
|
|
18
|
-
* @param {
|
|
18
|
+
* @param {ReactNode} children overlay content, centered (takes precedence)
|
|
19
|
+
* @param {ReactNode} loader full-screen loading curtain, e.g. foundry's ColorLoader
|
|
19
20
|
*/
|
|
20
|
-
export default function LoaderOverlay({ children,
|
|
21
|
+
export default function LoaderOverlay({ children, loader }) {
|
|
21
22
|
return (
|
|
22
23
|
<FullscreenOverlay open>
|
|
23
|
-
{children || (
|
|
24
|
-
<div className="fixed inset-0">
|
|
25
|
-
<ColorLoader onComplete={onEnter} dismissOnClick />
|
|
26
|
-
</div>
|
|
27
|
-
)}
|
|
24
|
+
{children || (loader ? <div className="fixed inset-0">{loader}</div> : null)}
|
|
28
25
|
</FullscreenOverlay>
|
|
29
26
|
)
|
|
30
27
|
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import MediaViewer from './MediaViewer.jsx'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* MediaTileGallery — a stack or grid of framed media tiles that open THE
|
|
6
|
+
* shared fullscreen MediaViewer at the clicked tile, paged across all items
|
|
7
|
+
* (the monorepo's FullscreenGallery recreated on the shared viewer instead of
|
|
8
|
+
* its nav-less single image; the AssetFigure/AssetGrid deps are dropped —
|
|
9
|
+
* tile and grid are inlined).
|
|
10
|
+
*
|
|
11
|
+
* Tiles are real <button>s, so Space + Enter activation comes free. Captions
|
|
12
|
+
* render verbatim — authored case, no auto-casing.
|
|
13
|
+
*
|
|
14
|
+
* @param {Array} items [{ url, alt?, kind?: 'image' | 'video', caption? }] — the viewer's media shape
|
|
15
|
+
* @param {string} layout 'stack' (flow column, default) | 'grid'
|
|
16
|
+
* @param {number} cols grid column count (grid layout only)
|
|
17
|
+
* @param {string} tileClassName extra classes merged onto each tile button
|
|
18
|
+
*/
|
|
19
|
+
export default function MediaTileGallery({ items = [], layout = 'stack', cols = 4, tileClassName = '' }) {
|
|
20
|
+
const [current, setCurrent] = useState(null)
|
|
21
|
+
|
|
22
|
+
if (!items.length) return null
|
|
23
|
+
|
|
24
|
+
const isGrid = layout === 'grid'
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<>
|
|
28
|
+
<div
|
|
29
|
+
className={isGrid ? 'grid gap-4' : 'flex flex-col gap-4'}
|
|
30
|
+
style={isGrid ? { gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))` } : undefined}
|
|
31
|
+
>
|
|
32
|
+
{items.map((item, i) => (
|
|
33
|
+
<figure key={i} className="min-w-0">
|
|
34
|
+
<button
|
|
35
|
+
type="button"
|
|
36
|
+
className={`block w-full cursor-zoom-in overflow-hidden rounded border border-fg-08 bg-fg-04 ${tileClassName}`.trim()}
|
|
37
|
+
onClick={() => setCurrent(i)}
|
|
38
|
+
>
|
|
39
|
+
<img src={item.url} alt={item.alt || ''} loading="lazy" className="block h-auto w-full" />
|
|
40
|
+
</button>
|
|
41
|
+
{item.caption && (
|
|
42
|
+
<figcaption className="kol-helper-12 text-meta mt-2">{item.caption}</figcaption>
|
|
43
|
+
)}
|
|
44
|
+
</figure>
|
|
45
|
+
))}
|
|
46
|
+
</div>
|
|
47
|
+
|
|
48
|
+
<MediaViewer
|
|
49
|
+
open={current != null}
|
|
50
|
+
media={items}
|
|
51
|
+
index={current ?? 0}
|
|
52
|
+
onIndexChange={setCurrent}
|
|
53
|
+
onClose={() => setCurrent(null)}
|
|
54
|
+
/>
|
|
55
|
+
</>
|
|
56
|
+
)
|
|
57
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { useEffect,
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
import useEmblaCarousel from 'embla-carousel-react'
|
|
2
3
|
import { Icon } from '@kolkrabbi/kol-icons'
|
|
3
4
|
import FullscreenOverlay from '../atoms/FullscreenOverlay.jsx'
|
|
4
5
|
|
|
@@ -7,89 +8,113 @@ import FullscreenOverlay from '../atoms/FullscreenOverlay.jsx'
|
|
|
7
8
|
* lightbox/gallery opener (the monorepo's ImageLightbox, FullscreenGallery
|
|
8
9
|
* and WorkDetail's inline gallery all collapse onto this).
|
|
9
10
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
11
|
+
* Composed, not forked: the shell (scrim, scroll-lock, Escape, backdrop-close,
|
|
12
|
+
* close button, stacking) is FullscreenOverlay; paging (touch swipe, snap,
|
|
13
|
+
* wrap-around) is embla — the same core Carousel wraps, wired directly here
|
|
14
|
+
* because Carousel's API has no controlled index and its slide chrome is
|
|
15
|
+
* strip-width, not full-stage. ArrowLeft/ArrowRight bind to scrollPrev/Next.
|
|
13
16
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* revisit if momentum/preview-of-next is ever wanted.
|
|
17
|
+
* Controlled: the parent owns `index`. Embla's select event reports back
|
|
18
|
+
* through `onIndexChange`; an external `index` change scrolls the stage.
|
|
17
19
|
*
|
|
18
20
|
* @param {boolean} open viewer visible
|
|
19
|
-
* @param {Array} media [{ url, alt?, kind: 'image' | 'video' }]
|
|
20
|
-
* @param {number}
|
|
21
|
-
* @param {Function} onClose close request
|
|
21
|
+
* @param {Array} media [{ url, alt?, kind: 'image' | 'video', caption? }]
|
|
22
|
+
* @param {number} index active item index (parent-owned)
|
|
22
23
|
* @param {Function} onIndexChange fires with the new index on page
|
|
24
|
+
* @param {Function} onClose close request (Esc, backdrop, close button)
|
|
23
25
|
*/
|
|
24
|
-
export default function MediaViewer({ open, media = [], initialIndex = 0, onClose, onIndexChange }) {
|
|
25
|
-
const [index, setIndex] = useState(initialIndex)
|
|
26
|
-
const touchStart = useRef(null)
|
|
27
26
|
|
|
28
|
-
|
|
27
|
+
/* Inverse-tier chip tokens: the overlay scrim is surface-inverse, and .kol-overlay
|
|
28
|
+
* carries no surface-context class, so the standard fg ramp doesn't flip there. */
|
|
29
|
+
const CHIP =
|
|
30
|
+
'kol-embla-btn absolute top-1/2 z-10 hidden -translate-y-1/2 items-center justify-center border border-fg-inverse-16 text-inverse hover:border-fg-inverse-32 md:flex'
|
|
31
|
+
|
|
32
|
+
/* Mounted only while the overlay is open, so embla initializes fresh each open
|
|
33
|
+
* with `startIndex` frozen at mount — no reInit games on later index changes. */
|
|
34
|
+
function ViewerStage({ media, index, onIndexChange }) {
|
|
35
|
+
const [options] = useState(() => ({ loop: true, startIndex: index }))
|
|
36
|
+
const [emblaRef, emblaApi] = useEmblaCarousel(options)
|
|
37
|
+
|
|
38
|
+
// embla → parent: keep the parent authoritative over the active index
|
|
29
39
|
useEffect(() => {
|
|
30
|
-
if (
|
|
31
|
-
|
|
40
|
+
if (!emblaApi) return
|
|
41
|
+
const onSelect = () => onIndexChange?.(emblaApi.selectedScrollSnap())
|
|
42
|
+
emblaApi.on('select', onSelect)
|
|
43
|
+
return () => emblaApi.off('select', onSelect)
|
|
44
|
+
}, [emblaApi, onIndexChange])
|
|
32
45
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
onIndexChange?.(next)
|
|
38
|
-
return next
|
|
39
|
-
})
|
|
40
|
-
}
|
|
46
|
+
// parent → embla: external index changes scroll the stage
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
if (emblaApi && emblaApi.selectedScrollSnap() !== index) emblaApi.scrollTo(index)
|
|
49
|
+
}, [emblaApi, index])
|
|
41
50
|
|
|
51
|
+
// Escape lives in FullscreenOverlay; the viewer only adds arrow paging
|
|
42
52
|
useEffect(() => {
|
|
43
|
-
if (!
|
|
53
|
+
if (!emblaApi) return
|
|
44
54
|
const onKey = (e) => {
|
|
45
|
-
if (e.key === 'ArrowLeft')
|
|
46
|
-
if (e.key === 'ArrowRight')
|
|
55
|
+
if (e.key === 'ArrowLeft') emblaApi.scrollPrev()
|
|
56
|
+
if (e.key === 'ArrowRight') emblaApi.scrollNext()
|
|
47
57
|
}
|
|
48
58
|
document.addEventListener('keydown', onKey)
|
|
49
59
|
return () => document.removeEventListener('keydown', onKey)
|
|
50
|
-
}, [
|
|
51
|
-
|
|
52
|
-
const item = media[index]
|
|
53
|
-
if (!open || !item) return null
|
|
54
|
-
|
|
55
|
-
const onTouchStart = (e) => { touchStart.current = e.touches[0].clientX }
|
|
56
|
-
const onTouchEnd = (e) => {
|
|
57
|
-
if (touchStart.current == null) return
|
|
58
|
-
const delta = e.changedTouches[0].clientX - touchStart.current
|
|
59
|
-
if (delta > 50) page(-1)
|
|
60
|
-
if (delta < -50) page(1)
|
|
61
|
-
touchStart.current = null
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
const chip = 'absolute top-1/2 -translate-y-1/2 z-10 hidden md:flex items-center justify-center w-10 h-10 rounded-full bg-fg-04 hover:bg-fg-08 transition-colors cursor-pointer border-0'
|
|
60
|
+
}, [emblaApi])
|
|
65
61
|
|
|
66
62
|
return (
|
|
67
|
-
<
|
|
68
|
-
<div className="
|
|
69
|
-
|
|
70
|
-
|
|
63
|
+
<div className="relative w-full">
|
|
64
|
+
<div className="overflow-hidden" ref={emblaRef}>
|
|
65
|
+
<div className="flex items-center">
|
|
66
|
+
{media.map((item, i) => (
|
|
67
|
+
<figure key={i} className="flex min-w-0 flex-[0_0_100%] flex-col items-center justify-center gap-3">
|
|
68
|
+
{item.kind === 'video' ? (
|
|
69
|
+
<video
|
|
70
|
+
src={item.url}
|
|
71
|
+
autoPlay
|
|
72
|
+
muted
|
|
73
|
+
playsInline
|
|
74
|
+
loop
|
|
75
|
+
className="max-h-[90vh] max-w-full object-contain"
|
|
76
|
+
/>
|
|
77
|
+
) : (
|
|
78
|
+
<img src={item.url} alt={item.alt || ''} className="max-h-[90vh] max-w-full object-contain" />
|
|
79
|
+
)}
|
|
80
|
+
{item.caption && (
|
|
81
|
+
<figcaption className="kol-helper-12 text-fg-inverse-64">{item.caption}</figcaption>
|
|
82
|
+
)}
|
|
83
|
+
</figure>
|
|
84
|
+
))}
|
|
85
|
+
</div>
|
|
86
|
+
</div>
|
|
87
|
+
|
|
88
|
+
{media.length > 1 && (
|
|
89
|
+
<>
|
|
90
|
+
<button
|
|
91
|
+
type="button"
|
|
92
|
+
className={`${CHIP} left-4`}
|
|
93
|
+
aria-label="Previous"
|
|
94
|
+
onClick={(e) => { e.stopPropagation(); emblaApi?.scrollPrev() }}
|
|
95
|
+
>
|
|
71
96
|
<Icon name="chevron-left" size={20} />
|
|
72
97
|
</button>
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
muted
|
|
80
|
-
loop
|
|
81
|
-
playsInline
|
|
82
|
-
className="max-h-[85vh] max-w-full object-contain"
|
|
83
|
-
/>
|
|
84
|
-
) : (
|
|
85
|
-
<img src={item.url} alt={item.alt || ''} className="max-h-[85vh] max-w-full object-contain" />
|
|
86
|
-
)}
|
|
87
|
-
{media.length > 1 && (
|
|
88
|
-
<button type="button" className={`${chip} right-4`} aria-label="Next" onClick={() => page(1)}>
|
|
98
|
+
<button
|
|
99
|
+
type="button"
|
|
100
|
+
className={`${CHIP} right-4`}
|
|
101
|
+
aria-label="Next"
|
|
102
|
+
onClick={(e) => { e.stopPropagation(); emblaApi?.scrollNext() }}
|
|
103
|
+
>
|
|
89
104
|
<Icon name="chevron-right" size={20} />
|
|
90
105
|
</button>
|
|
91
|
-
|
|
92
|
-
|
|
106
|
+
</>
|
|
107
|
+
)}
|
|
108
|
+
</div>
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export default function MediaViewer({ open, media = [], index = 0, onIndexChange, onClose }) {
|
|
113
|
+
if (!open || !media[index]) return null
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
<FullscreenOverlay open onClose={onClose}>
|
|
117
|
+
<ViewerStage media={media} index={index} onIndexChange={onIndexChange} />
|
|
93
118
|
</FullscreenOverlay>
|
|
94
119
|
)
|
|
95
120
|
}
|
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* PriceDisplay — baseline-aligned price line: a large primary amount beside a
|
|
3
|
-
* small muted secondary note (a second-currency echo or a cost breakdown).
|
|
4
|
-
* Formats `amount` via Intl.NumberFormat in whole units (prints are priced in
|
|
5
|
-
* round numbers); `secondary` is authored at the call site, parens and all —
|
|
6
|
-
* omit it to render the primary alone.
|
|
7
|
-
*
|
|
8
|
-
* Presentational — pure scalars in, formatted currency out. Seed atom for the
|
|
9
|
-
* price/sale/edition family.
|
|
10
|
-
*
|
|
11
|
-
* @param {number} amount primary price, run through the currency formatter
|
|
12
|
-
* @param {string} currency ISO 4217 code for `amount` (e.g. 'EUR', 'ISK')
|
|
13
|
-
* @param {string} locale number-format locale
|
|
14
|
-
* @param {ReactNode} secondary muted trailing note (omit to hide)
|
|
15
|
-
*/
|
|
16
|
-
export default function PriceDisplay({
|
|
17
|
-
amount,
|
|
18
|
-
currency = 'EUR',
|
|
19
|
-
locale = 'de-DE',
|
|
20
|
-
secondary,
|
|
21
|
-
}) {
|
|
22
|
-
const formatted = new Intl.NumberFormat(locale, {
|
|
23
|
-
style: 'currency',
|
|
24
|
-
currency,
|
|
25
|
-
maximumFractionDigits: 0,
|
|
26
|
-
}).format(amount)
|
|
27
|
-
|
|
28
|
-
return (
|
|
29
|
-
<div className="flex flex-wrap items-baseline gap-3">
|
|
30
|
-
<span className="kol-sans-heading-03">{formatted}</span>
|
|
31
|
-
{secondary != null && <span className="kol-mono-12 text-fg-48">{secondary}</span>}
|
|
32
|
-
</div>
|
|
33
|
-
)
|
|
34
|
-
}
|