@inkra/react 0.1.0 → 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/README.md +104 -1
- package/dist/PencilIcon.js +14 -16
- package/dist/PencilLine.js +18 -12
- package/dist/PencilText.js +192 -59
- package/dist/alert-dialog/index.d.ts +64 -0
- package/dist/alert-dialog/index.js +109 -0
- package/dist/audio-player/index.d.ts +60 -0
- package/dist/audio-player/index.js +101 -0
- package/dist/carousel/index.d.ts +51 -0
- package/dist/carousel/index.js +290 -0
- package/dist/choice-grid/index.d.ts +38 -0
- package/dist/choice-grid/index.js +85 -0
- package/dist/color-picker/color.d.ts +15 -0
- package/dist/color-picker/color.js +55 -0
- package/dist/color-picker/index.d.ts +31 -0
- package/dist/color-picker/index.js +69 -0
- package/dist/command/index.d.ts +23 -0
- package/dist/command/index.js +39 -0
- package/dist/context-menu/index.d.ts +76 -0
- package/dist/context-menu/index.js +240 -0
- package/dist/data-table/index.d.ts +47 -0
- package/dist/data-table/index.js +91 -0
- package/dist/date-picker/index.d.ts +50 -0
- package/dist/date-picker/index.js +180 -0
- package/dist/display/ProgressRing.d.ts +6 -0
- package/dist/display/ProgressRing.js +146 -0
- package/dist/display/index.d.ts +27 -0
- package/dist/display/index.js +18 -2
- package/dist/dockable-panel/index.d.ts +41 -0
- package/dist/dockable-panel/index.js +253 -0
- package/dist/dockable-panel/model.d.ts +21 -0
- package/dist/dockable-panel/model.js +61 -0
- package/dist/drawer/index.d.ts +63 -0
- package/dist/drawer/index.js +86 -0
- package/dist/editable/index.d.ts +53 -0
- package/dist/editable/index.js +162 -0
- package/dist/empty/index.d.ts +21 -0
- package/dist/empty/index.js +23 -0
- package/dist/flip/index.d.ts +49 -0
- package/dist/flip/index.js +138 -0
- package/dist/form/EditablePencilText.js +157 -146
- package/dist/form/Textarea.d.ts +12 -0
- package/dist/form/Textarea.js +83 -4
- package/dist/form/index.d.ts +1 -1
- package/dist/heatmap/index.d.ts +59 -0
- package/dist/heatmap/index.js +248 -0
- package/dist/hotkey/index.d.ts +37 -0
- package/dist/hotkey/index.js +130 -0
- package/dist/hotkey/model.d.ts +27 -0
- package/dist/hotkey/model.js +97 -0
- package/dist/hover-card/index.d.ts +33 -0
- package/dist/hover-card/index.js +141 -0
- package/dist/image-cropper/index.d.ts +82 -0
- package/dist/image-cropper/index.js +261 -0
- package/dist/image-cropper/model.d.ts +42 -0
- package/dist/image-cropper/model.js +94 -0
- package/dist/image-viewer/index.d.ts +79 -0
- package/dist/image-viewer/index.js +220 -0
- package/dist/image-viewer/model.d.ts +9 -0
- package/dist/image-viewer/model.js +18 -0
- package/dist/index.d.ts +47 -0
- package/dist/index.js +47 -0
- package/dist/internal/cache.d.ts +1 -1
- package/dist/internal/cache.js +1 -1
- package/dist/internal/renderTokens.d.ts +10 -0
- package/dist/internal/renderTokens.js +13 -0
- package/dist/internal/surfaceRenderer.d.ts +14 -6
- package/dist/internal/surfaceRenderer.js +15 -12
- package/dist/internal/textRenderer.d.ts +1 -0
- package/dist/internal/textRenderer.js +33 -7
- package/dist/internal/usePencilSurface.js +16 -8
- package/dist/internal/useSelectionDrawing.js +15 -11
- package/dist/journey-map/MapScale.js +3 -1
- package/dist/journey-map/index.js +3 -1
- package/dist/keypad/index.d.ts +23 -0
- package/dist/keypad/index.js +63 -0
- package/dist/keypad/model.d.ts +20 -0
- package/dist/keypad/model.js +33 -0
- package/dist/layout/index.d.ts +63 -0
- package/dist/layout/index.js +62 -0
- package/dist/log/index.d.ts +45 -0
- package/dist/log/index.js +159 -0
- package/dist/log/model.d.ts +16 -0
- package/dist/log/model.js +13 -0
- package/dist/node-graph/index.d.ts +123 -0
- package/dist/node-graph/index.js +304 -0
- package/dist/number-ticker/index.d.ts +29 -0
- package/dist/number-ticker/index.js +132 -0
- package/dist/number-ticker/model.d.ts +8 -0
- package/dist/number-ticker/model.js +34 -0
- package/dist/overflow-list/index.d.ts +39 -0
- package/dist/overflow-list/index.js +230 -0
- package/dist/overflow-list/layout.d.ts +19 -0
- package/dist/overflow-list/layout.js +44 -0
- package/dist/pagination/index.d.ts +23 -0
- package/dist/pagination/index.js +41 -0
- package/dist/pagination/model.d.ts +4 -0
- package/dist/pagination/model.js +42 -0
- package/dist/pan-zoom/index.d.ts +71 -0
- package/dist/pan-zoom/index.js +175 -0
- package/dist/radar/index.d.ts +54 -0
- package/dist/radar/index.js +220 -0
- package/dist/range-slider/index.d.ts +16 -0
- package/dist/range-slider/index.js +22 -0
- package/dist/rating/index.d.ts +28 -0
- package/dist/rating/index.js +127 -0
- package/dist/resizable/index.d.ts +32 -0
- package/dist/resizable/index.js +50 -0
- package/dist/scroll-area/index.d.ts +36 -0
- package/dist/scroll-area/index.js +44 -0
- package/dist/segmented-progress/SegmentedProgress.d.ts +17 -0
- package/dist/segmented-progress/SegmentedProgress.js +42 -0
- package/dist/slider/index.js +1 -1
- package/dist/slot/index.d.ts +24 -0
- package/dist/slot/index.js +29 -0
- package/dist/sortable/index.d.ts +50 -0
- package/dist/sortable/index.js +278 -0
- package/dist/sortable/model.d.ts +30 -0
- package/dist/sortable/model.js +64 -0
- package/dist/sparkline/index.d.ts +19 -0
- package/dist/sparkline/index.js +240 -0
- package/dist/sparkline/model.d.ts +16 -0
- package/dist/sparkline/model.js +28 -0
- package/dist/steps/index.d.ts +16 -0
- package/dist/steps/index.js +37 -0
- package/dist/styles.css +4850 -287
- package/dist/tag-input/index.d.ts +69 -0
- package/dist/tag-input/index.js +325 -0
- package/dist/tag-input/model.d.ts +14 -0
- package/dist/tag-input/model.js +38 -0
- package/dist/time-picker/index.d.ts +15 -0
- package/dist/time-picker/index.js +168 -0
- package/dist/timeline/index.d.ts +14 -0
- package/dist/timeline/index.js +16 -0
- package/dist/toast/index.d.ts +27 -0
- package/dist/toast/index.js +36 -0
- package/dist/toggle-group/index.d.ts +17 -0
- package/dist/toggle-group/index.js +26 -0
- package/dist/tokens.css +10 -9
- package/dist/transfer/index.d.ts +64 -0
- package/dist/transfer/index.js +174 -0
- package/dist/transfer/model.d.ts +12 -0
- package/dist/transfer/model.js +33 -0
- package/dist/tree/index.d.ts +39 -0
- package/dist/tree/index.js +36 -0
- package/dist/tree-select/index.d.ts +59 -0
- package/dist/tree-select/index.js +142 -0
- package/dist/tree-select/model.d.ts +31 -0
- package/dist/tree-select/model.js +135 -0
- package/dist/typewriter/index.d.ts +21 -0
- package/dist/typewriter/index.js +101 -0
- package/dist/typewriter/model.d.ts +7 -0
- package/dist/typewriter/model.js +45 -0
- package/dist/upload/index.d.ts +59 -0
- package/dist/upload/index.js +230 -0
- package/dist/virtual-list/index.d.ts +44 -0
- package/dist/virtual-list/index.js +128 -0
- package/dist/virtual-list/model.d.ts +7 -0
- package/dist/virtual-list/model.js +27 -0
- package/package.json +22 -3
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { forwardRef, useCallback, useEffect, useId, useImperativeHandle, useRef, useState } from 'react';
|
|
3
|
+
import { Button } from '../Button.js';
|
|
4
|
+
import { PencilIcon } from '../PencilIcon.js';
|
|
5
|
+
import { PencilSurface } from '../PencilSurface.js';
|
|
6
|
+
import { PencilText } from '../PencilText.js';
|
|
7
|
+
import { Select } from '../select/index.js';
|
|
8
|
+
import { Slider } from '../slider/index.js';
|
|
9
|
+
const pausePaths = [
|
|
10
|
+
{ d: 'M 15 3 H 18 A 1 1 0 0 1 19 4 V 20 A 1 1 0 0 1 18 21 H 15 A 1 1 0 0 1 14 20 V 4 A 1 1 0 0 1 15 3 Z' },
|
|
11
|
+
{ d: 'M 6 3 H 9 A 1 1 0 0 1 10 4 V 20 A 1 1 0 0 1 9 21 H 6 A 1 1 0 0 1 5 20 V 4 A 1 1 0 0 1 6 3 Z' },
|
|
12
|
+
];
|
|
13
|
+
const playPaths = [{ d: 'M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z' }];
|
|
14
|
+
const volumePaths = [
|
|
15
|
+
{ d: 'M11 4.702a.705.705 0 0 0-1.203-.498L6.413 7.587A1.4 1.4 0 0 1 5.416 8H3a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2.416a1.4 1.4 0 0 1 .997.413l3.383 3.384A.705.705 0 0 0 11 19.298z' },
|
|
16
|
+
{ d: 'M16 9a5 5 0 0 1 0 6' },
|
|
17
|
+
{ d: 'M19.364 18.364a9 9 0 0 0 0-12.728' },
|
|
18
|
+
];
|
|
19
|
+
const mutePaths = [
|
|
20
|
+
{ d: 'M11 4.702a.7.7 0 0 0-1.203-.498L6.413 7.587A1.4 1.4 0 0 1 5.416 8H3a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2.416a1.4 1.4 0 0 1 .997.413l3.383 3.384A.7.7 0 0 0 11 19.298z' },
|
|
21
|
+
{ d: 'm16.5 14.5 5-5' },
|
|
22
|
+
{ d: 'm16.5 9.5 5 5' },
|
|
23
|
+
];
|
|
24
|
+
const DEFAULT_LABELS = {
|
|
25
|
+
play: 'Play', pause: 'Pause', mute: 'Mute', unmute: 'Unmute', seek: 'Seek', volume: 'Volume', rate: 'Playback speed', loading: 'Loading audio', blocked: 'Playback was blocked. Press play to try again.', error: 'Audio failed to load.',
|
|
26
|
+
};
|
|
27
|
+
const rates = [0.75, 1, 1.25, 1.5, 2];
|
|
28
|
+
function clamp(value, min, max) { return Number.isFinite(value) ? Math.min(max, Math.max(min, value)) : min; }
|
|
29
|
+
function formatTime(value) { if (!Number.isFinite(value) || value < 0)
|
|
30
|
+
return '0:00'; const seconds = Math.floor(value); return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`; }
|
|
31
|
+
export const AudioPlayer = forwardRef(function AudioPlayer({ src, preload = 'metadata', autoPlay = false, crossOrigin, className, defaultMuted = false, defaultPlaybackRate = 1, defaultVolume = 1, volume, muted, playbackRate, onVolumeChange, onMutedChange, onPlaybackRateChange, onTimeChange, onStateChange, onError, labels, renderControls, audioProps, ...props }, forwardedRef) {
|
|
32
|
+
const id = useId();
|
|
33
|
+
const audioRef = useRef(null);
|
|
34
|
+
const rootRef = useRef(null);
|
|
35
|
+
const [state, setState] = useState({ status: src ? 'loading' : 'idle', currentTime: 0, duration: 0, buffered: 0, volume: clamp(volume ?? defaultVolume, 0, 1), muted: muted ?? defaultMuted, playbackRate: clamp(playbackRate ?? defaultPlaybackRate, 0.25, 4), canPlay: false, error: null });
|
|
36
|
+
const stateRef = useRef(state);
|
|
37
|
+
stateRef.current = state;
|
|
38
|
+
const mergedLabels = { ...DEFAULT_LABELS, ...labels };
|
|
39
|
+
const update = useCallback((patch) => {
|
|
40
|
+
const next = { ...stateRef.current, ...patch };
|
|
41
|
+
stateRef.current = next;
|
|
42
|
+
setState(next);
|
|
43
|
+
onStateChange?.(next);
|
|
44
|
+
}, [onStateChange]);
|
|
45
|
+
const sync = useCallback(() => {
|
|
46
|
+
const audio = audioRef.current;
|
|
47
|
+
if (!audio)
|
|
48
|
+
return;
|
|
49
|
+
const buffered = audio.duration > 0 && audio.buffered.length ? audio.buffered.end(audio.buffered.length - 1) / audio.duration : 0;
|
|
50
|
+
update({ currentTime: Number.isFinite(audio.currentTime) ? audio.currentTime : 0, duration: Number.isFinite(audio.duration) ? audio.duration : 0, buffered: clamp(buffered, 0, 1), volume: audio.volume, muted: audio.muted, playbackRate: audio.playbackRate, canPlay: audio.readyState >= 3 });
|
|
51
|
+
}, [update]);
|
|
52
|
+
const play = useCallback(async () => { const audio = audioRef.current; if (!audio)
|
|
53
|
+
return; try {
|
|
54
|
+
await audio.play();
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
update({ status: 'blocked' });
|
|
58
|
+
onError?.(error instanceof DOMException ? error : null);
|
|
59
|
+
} }, [onError, update]);
|
|
60
|
+
const pause = useCallback(() => { audioRef.current?.pause(); }, []);
|
|
61
|
+
const togglePlay = useCallback(async () => { const audio = audioRef.current; if (!audio)
|
|
62
|
+
return; if (audio.paused)
|
|
63
|
+
await play();
|
|
64
|
+
else
|
|
65
|
+
pause(); }, [pause, play]);
|
|
66
|
+
const seek = useCallback((time) => { const audio = audioRef.current; if (!audio || !Number.isFinite(time))
|
|
67
|
+
return; audio.currentTime = clamp(time, 0, Number.isFinite(audio.duration) ? audio.duration : Math.max(0, time)); sync(); onTimeChange?.(audio.currentTime); }, [onTimeChange, sync]);
|
|
68
|
+
const setVolume = useCallback((next) => { const audio = audioRef.current; if (!audio)
|
|
69
|
+
return; audio.volume = clamp(next, 0, 1); onVolumeChange?.(audio.volume); sync(); }, [onVolumeChange, sync]);
|
|
70
|
+
const setMuted = useCallback((next) => { const audio = audioRef.current; if (!audio)
|
|
71
|
+
return; audio.muted = next; onMutedChange?.(next); sync(); }, [onMutedChange, sync]);
|
|
72
|
+
const setPlaybackRate = useCallback((next) => { const audio = audioRef.current; if (!audio)
|
|
73
|
+
return; audio.playbackRate = clamp(next, 0.25, 4); onPlaybackRateChange?.(audio.playbackRate); sync(); }, [onPlaybackRateChange, sync]);
|
|
74
|
+
const focus = useCallback(() => { rootRef.current?.querySelector('[data-audio-player-control]')?.focus(); }, []);
|
|
75
|
+
const controls = { audio: audioRef.current, state, play, pause, togglePlay, seek, setVolume, setMuted, setPlaybackRate, focus };
|
|
76
|
+
useImperativeHandle(forwardedRef, () => controls, [state, play, pause, togglePlay, seek, setVolume, setMuted, setPlaybackRate, focus]);
|
|
77
|
+
useEffect(() => { const audio = audioRef.current; if (!audio)
|
|
78
|
+
return; audio.volume = clamp(volume ?? defaultVolume, 0, 1); audio.muted = muted ?? defaultMuted; audio.playbackRate = clamp(playbackRate ?? defaultPlaybackRate, 0.25, 4); sync(); }, [defaultMuted, defaultPlaybackRate, defaultVolume, muted, playbackRate, src, volume, sync]);
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
const audio = audioRef.current;
|
|
81
|
+
if (!audio || !src)
|
|
82
|
+
return;
|
|
83
|
+
audio.pause();
|
|
84
|
+
audio.currentTime = 0;
|
|
85
|
+
update({ status: 'loading', currentTime: 0, duration: 0, buffered: 0, canPlay: false, error: null });
|
|
86
|
+
audio.load();
|
|
87
|
+
}, [src, update]);
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
const audio = audioRef.current;
|
|
90
|
+
if (!audio)
|
|
91
|
+
return;
|
|
92
|
+
const handlers = {
|
|
93
|
+
loadstart: () => update({ status: 'loading', error: null, canPlay: false }), loadedmetadata: () => { sync(); update({ status: 'ready' }); }, canplay: () => { sync(); update({ canPlay: true, status: audio.paused ? 'paused' : 'playing' }); }, play: () => update({ status: 'playing' }), pause: () => update({ status: audio.ended ? 'ended' : 'paused' }), playing: () => update({ status: 'playing', canPlay: true }), waiting: () => update({ status: 'buffering' }), progress: sync, timeupdate: () => { sync(); onTimeChange?.(audio.currentTime); }, ratechange: sync, volumechange: () => { sync(); onVolumeChange?.(audio.volume); onMutedChange?.(audio.muted); }, ended: () => update({ status: 'ended', currentTime: audio.duration || audio.currentTime }), error: () => { const error = audio.error; update({ status: 'error', error }); onError?.(error); }, abort: () => update({ status: 'idle' }),
|
|
94
|
+
};
|
|
95
|
+
Object.entries(handlers).forEach(([name, handler]) => audio.addEventListener(name, handler));
|
|
96
|
+
return () => Object.entries(handlers).forEach(([name, handler]) => audio.removeEventListener(name, handler));
|
|
97
|
+
}, [onError, onMutedChange, onTimeChange, onVolumeChange, sync, src, update]);
|
|
98
|
+
const playLabel = state.status === 'playing' ? mergedLabels.pause : mergedLabels.play;
|
|
99
|
+
const defaultControls = _jsxs("div", { className: "inkra-audio-player__controls", role: "group", "aria-label": "Audio controls", children: [_jsx(Button, { "data-audio-player-control": true, "data-tooltip": playLabel, type: "button", size: "icon-sm", variant: "outline", "aria-label": playLabel, onClick: () => void togglePlay(), children: _jsx(PencilIcon, { paths: state.status === 'playing' ? pausePaths : playPaths, size: 20, sourceSize: 24 }) }), _jsx("div", { className: "inkra-audio-player__seek", children: _jsx(Slider, { "data-audio-player-control": true, min: 0, max: state.duration || 0, step: 0.01, value: [state.currentTime], onValueChange: values => seek(values[0] ?? 0), disabled: !state.duration, "aria-label": mergedLabels.seek, getValueLabel: value => labels?.time?.(value, state.duration) ?? `${formatTime(value)} / ${formatTime(state.duration)}`, size: "sm", variant: "inset" }) }), _jsx(PencilText, { as: "span", className: "inkra-audio-player__time", "aria-live": "off", children: labels?.time?.(state.currentTime, state.duration) ?? `${formatTime(state.currentTime)} / ${formatTime(state.duration)}` }), _jsxs("div", { className: "inkra-audio-player__settings", children: [_jsx(Button, { "data-audio-player-control": true, "data-tooltip": state.muted ? mergedLabels.unmute : mergedLabels.mute, type: "button", size: "icon-sm", variant: "ghost", "aria-label": state.muted ? mergedLabels.unmute : mergedLabels.mute, onClick: () => setMuted(!state.muted), children: _jsx(PencilIcon, { paths: state.muted ? mutePaths : volumePaths, size: 20, sourceSize: 24 }) }), _jsx("div", { className: "inkra-audio-player__volume", children: _jsx(Slider, { "data-audio-player-control": true, min: 0, max: 1, step: 0.01, value: [state.muted ? 0 : state.volume], onValueChange: values => { setMuted(false); setVolume(values[0] ?? 0); }, "aria-label": mergedLabels.volume, getValueLabel: value => `${Math.round(value * 100)}%`, size: "sm", variant: "inset" }) }), _jsxs(Select.Root, { value: String(state.playbackRate), onValueChange: value => setPlaybackRate(Number(value)), children: [_jsx(Select.Trigger, { "data-audio-player-control": true, "aria-label": mergedLabels.rate, fullWidth: false, size: "sm", children: _jsx(Select.Value, {}) }), _jsx(Select.Portal, { children: _jsx(Select.Content, { position: "popper", sideOffset: 4, children: _jsx(Select.Viewport, { children: rates.map(rate => _jsxs(Select.Item, { value: String(rate), children: [_jsx(Select.ItemText, { children: `${rate}x` }), _jsx(Select.ItemIndicator, {})] }, rate)) }) }) })] })] })] });
|
|
100
|
+
return _jsxs(PencilSurface, { ref: rootRef, ...props, className: ['inkra-audio-player', className].filter(Boolean).join(' '), variant: "paper", radius: 8, "data-status": state.status, "data-audio-player-id": id, children: [_jsx("audio", { ...audioProps, ref: audioRef, "aria-label": audioProps?.['aria-label'] ?? 'Audio', autoPlay: autoPlay, crossOrigin: crossOrigin, muted: state.muted, preload: preload, src: src }), state.status === 'blocked' && _jsx(PencilText, { as: "p", className: "inkra-audio-player__message", role: "status", children: mergedLabels.blocked }), state.status === 'error' && _jsx(PencilText, { as: "p", className: "inkra-audio-player__message", role: "alert", children: mergedLabels.error }), renderControls ? renderControls(controls) : defaultControls] });
|
|
101
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { HTMLAttributes, ReactNode } from 'react';
|
|
2
|
+
export interface CarouselVisibleItems {
|
|
3
|
+
base?: number;
|
|
4
|
+
sm?: number;
|
|
5
|
+
md?: number;
|
|
6
|
+
lg?: number;
|
|
7
|
+
}
|
|
8
|
+
export interface CarouselAutoplayOptions {
|
|
9
|
+
delay?: number;
|
|
10
|
+
pauseOnHover?: boolean;
|
|
11
|
+
pauseOnFocus?: boolean;
|
|
12
|
+
stopOnInteraction?: boolean;
|
|
13
|
+
respectReducedMotion?: boolean;
|
|
14
|
+
}
|
|
15
|
+
export interface CarouselLabels {
|
|
16
|
+
previous?: string;
|
|
17
|
+
next?: string;
|
|
18
|
+
pause?: string;
|
|
19
|
+
play?: string;
|
|
20
|
+
pages?: string;
|
|
21
|
+
empty?: string;
|
|
22
|
+
slide?: (index: number, count: number) => string;
|
|
23
|
+
page?: (index: number, count: number) => string;
|
|
24
|
+
status?: (index: number, count: number) => string;
|
|
25
|
+
}
|
|
26
|
+
export interface CarouselProps extends Omit<HTMLAttributes<HTMLDivElement>, 'children' | 'onChange'> {
|
|
27
|
+
children?: ReactNode;
|
|
28
|
+
currentIndex?: number;
|
|
29
|
+
defaultIndex?: number;
|
|
30
|
+
onCurrentIndexChange?: (index: number) => void;
|
|
31
|
+
visibleItems?: number | CarouselVisibleItems;
|
|
32
|
+
gap?: number;
|
|
33
|
+
align?: 'start' | 'center' | 'end';
|
|
34
|
+
loop?: boolean;
|
|
35
|
+
dragFree?: boolean;
|
|
36
|
+
disabled?: boolean;
|
|
37
|
+
controls?: boolean;
|
|
38
|
+
pagination?: boolean;
|
|
39
|
+
autoplay?: boolean | CarouselAutoplayOptions;
|
|
40
|
+
emptyState?: ReactNode;
|
|
41
|
+
labels?: CarouselLabels;
|
|
42
|
+
}
|
|
43
|
+
export interface CarouselRef {
|
|
44
|
+
root: HTMLDivElement | null;
|
|
45
|
+
previous: () => void;
|
|
46
|
+
next: () => void;
|
|
47
|
+
scrollTo: (index: number) => void;
|
|
48
|
+
play: () => void;
|
|
49
|
+
pause: () => void;
|
|
50
|
+
}
|
|
51
|
+
export declare const Carousel: import("react").ForwardRefExoticComponent<CarouselProps & import("react").RefAttributes<CarouselRef>>;
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import Autoplay from 'embla-carousel-autoplay';
|
|
3
|
+
import useEmblaCarousel from 'embla-carousel-react';
|
|
4
|
+
import { Children, forwardRef, useCallback, useEffect, useId, useImperativeHandle, useMemo, useRef, useState, } from 'react';
|
|
5
|
+
import { Button } from '../Button.js';
|
|
6
|
+
import { PencilIcon } from '../PencilIcon.js';
|
|
7
|
+
import { PencilSurface } from '../PencilSurface.js';
|
|
8
|
+
const previousPaths = [{ d: 'm15 18-6-6 6-6' }];
|
|
9
|
+
const nextPaths = [{ d: 'm9 18 6-6-6-6' }];
|
|
10
|
+
const pausePaths = [{ d: 'M8 5v14M16 5v14' }];
|
|
11
|
+
const playPaths = [{ d: 'm7 4 12 8-12 8Z' }];
|
|
12
|
+
function finiteInteger(value, fallback, minimum = 1, maximum = 12) {
|
|
13
|
+
return Number.isFinite(value) ? Math.min(maximum, Math.max(minimum, Math.round(value))) : fallback;
|
|
14
|
+
}
|
|
15
|
+
function clampIndex(value, count) {
|
|
16
|
+
if (count === 0)
|
|
17
|
+
return 0;
|
|
18
|
+
const index = Number.isFinite(value) ? Math.round(value) : 0;
|
|
19
|
+
return Math.min(count - 1, Math.max(0, index));
|
|
20
|
+
}
|
|
21
|
+
function visibleItemValues(value) {
|
|
22
|
+
if (typeof value === 'number') {
|
|
23
|
+
const fixed = finiteInteger(value, 1);
|
|
24
|
+
return { base: fixed, sm: fixed, md: fixed, lg: fixed };
|
|
25
|
+
}
|
|
26
|
+
const base = finiteInteger(value?.base, 1);
|
|
27
|
+
const sm = finiteInteger(value?.sm, base);
|
|
28
|
+
const md = finiteInteger(value?.md, sm);
|
|
29
|
+
const lg = finiteInteger(value?.lg, md);
|
|
30
|
+
return { base, sm, md, lg };
|
|
31
|
+
}
|
|
32
|
+
function useReducedMotion() {
|
|
33
|
+
const [reduced, setReduced] = useState(false);
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
const query = window.matchMedia('(prefers-reduced-motion: reduce)');
|
|
36
|
+
const update = () => setReduced(query.matches);
|
|
37
|
+
update();
|
|
38
|
+
query.addEventListener('change', update);
|
|
39
|
+
return () => query.removeEventListener('change', update);
|
|
40
|
+
}, []);
|
|
41
|
+
return reduced;
|
|
42
|
+
}
|
|
43
|
+
export const Carousel = forwardRef(function Carousel({ 'aria-label': ariaLabel = 'Carousel', align = 'start', autoplay = false, children, className, controls = true, currentIndex, defaultIndex = 0, dir, disabled = false, dragFree = false, emptyState, gap = 16, labels, loop = false, onBlurCapture, onCurrentIndexChange, onFocusCapture, onKeyDown, onPointerEnter, onPointerLeave, pagination = true, role = 'region', style, tabIndex, visibleItems = { base: 1, sm: 2, md: 3 }, ...props }, forwardedRef) {
|
|
44
|
+
const generatedId = useId();
|
|
45
|
+
const rootRef = useRef(null);
|
|
46
|
+
const slideItems = Children.toArray(children);
|
|
47
|
+
const count = slideItems.length;
|
|
48
|
+
const controlled = currentIndex !== undefined;
|
|
49
|
+
const initialIndex = clampIndex(controlled ? currentIndex : defaultIndex, count);
|
|
50
|
+
const [uncontrolledIndex, setUncontrolledIndex] = useState(initialIndex);
|
|
51
|
+
const [selectedIndex, setSelectedIndex] = useState(initialIndex);
|
|
52
|
+
const [scrollSnaps, setScrollSnaps] = useState([]);
|
|
53
|
+
const [canScrollPrevious, setCanScrollPrevious] = useState(false);
|
|
54
|
+
const [canScrollNext, setCanScrollNext] = useState(false);
|
|
55
|
+
const [inferredDir, setInferredDir] = useState('ltr');
|
|
56
|
+
const [playing, setPlaying] = useState(false);
|
|
57
|
+
const hoveredRef = useRef(false);
|
|
58
|
+
const focusedRef = useRef(false);
|
|
59
|
+
const userPausedRef = useRef(false);
|
|
60
|
+
const autoplayControlPointerRef = useRef(false);
|
|
61
|
+
const reducedMotion = useReducedMotion();
|
|
62
|
+
const selectionCount = scrollSnaps.length || count;
|
|
63
|
+
const activeIndex = clampIndex(controlled ? currentIndex : uncontrolledIndex, selectionCount);
|
|
64
|
+
const resolvedDir = dir === 'rtl' ? 'rtl' : dir === 'ltr' ? 'ltr' : inferredDir;
|
|
65
|
+
const autoplayOptions = typeof autoplay === 'object' ? autoplay : {};
|
|
66
|
+
const stopOnInteraction = autoplayOptions.stopOnInteraction ?? true;
|
|
67
|
+
const autoplayEnabled = Boolean(autoplay) && count > 1 && !disabled;
|
|
68
|
+
const autoplayAllowed = autoplayEnabled && !(autoplayOptions.respectReducedMotion !== false && reducedMotion);
|
|
69
|
+
const requestedVisible = visibleItemValues(visibleItems);
|
|
70
|
+
const visible = count > 0 ? {
|
|
71
|
+
base: Math.min(count, requestedVisible.base),
|
|
72
|
+
sm: Math.min(count, requestedVisible.sm),
|
|
73
|
+
md: Math.min(count, requestedVisible.md),
|
|
74
|
+
lg: Math.min(count, requestedVisible.lg),
|
|
75
|
+
} : requestedVisible;
|
|
76
|
+
const resolvedGap = Number.isFinite(gap) ? Math.max(0, gap) : 16;
|
|
77
|
+
const autoplayActive = Boolean(autoplay);
|
|
78
|
+
const autoplayPlugin = useMemo(() => Autoplay({
|
|
79
|
+
active: autoplayActive,
|
|
80
|
+
delay: Number.isFinite(autoplayOptions.delay) ? Math.max(1000, autoplayOptions.delay) : 5000,
|
|
81
|
+
playOnInit: false,
|
|
82
|
+
stopOnFocusIn: false,
|
|
83
|
+
stopOnInteraction,
|
|
84
|
+
stopOnMouseEnter: false,
|
|
85
|
+
}), [autoplayActive, autoplayOptions.delay, stopOnInteraction]);
|
|
86
|
+
const plugins = useMemo(() => autoplayActive ? [autoplayPlugin] : [], [autoplayActive, autoplayPlugin]);
|
|
87
|
+
const [viewportRef, emblaApi] = useEmblaCarousel({
|
|
88
|
+
active: !disabled && count > 0,
|
|
89
|
+
align,
|
|
90
|
+
containScroll: 'trimSnaps',
|
|
91
|
+
direction: resolvedDir,
|
|
92
|
+
dragFree,
|
|
93
|
+
loop: loop && count > 1,
|
|
94
|
+
slidesToScroll: 1,
|
|
95
|
+
startIndex: initialIndex,
|
|
96
|
+
watchResize: true,
|
|
97
|
+
watchSlides: true,
|
|
98
|
+
}, plugins);
|
|
99
|
+
const refresh = useCallback(() => {
|
|
100
|
+
if (!emblaApi)
|
|
101
|
+
return;
|
|
102
|
+
const nextIndex = clampIndex(emblaApi.selectedScrollSnap(), count);
|
|
103
|
+
setSelectedIndex(nextIndex);
|
|
104
|
+
setScrollSnaps(emblaApi.scrollSnapList());
|
|
105
|
+
setCanScrollPrevious(emblaApi.canScrollPrev());
|
|
106
|
+
setCanScrollNext(emblaApi.canScrollNext());
|
|
107
|
+
}, [count, emblaApi]);
|
|
108
|
+
const select = useCallback(() => {
|
|
109
|
+
if (!emblaApi)
|
|
110
|
+
return;
|
|
111
|
+
const nextIndex = clampIndex(emblaApi.selectedScrollSnap(), count);
|
|
112
|
+
setSelectedIndex(nextIndex);
|
|
113
|
+
setCanScrollPrevious(emblaApi.canScrollPrev());
|
|
114
|
+
setCanScrollNext(emblaApi.canScrollNext());
|
|
115
|
+
if (!controlled)
|
|
116
|
+
setUncontrolledIndex(nextIndex);
|
|
117
|
+
if (nextIndex !== activeIndex)
|
|
118
|
+
onCurrentIndexChange?.(nextIndex);
|
|
119
|
+
}, [activeIndex, controlled, count, emblaApi, onCurrentIndexChange]);
|
|
120
|
+
useEffect(() => {
|
|
121
|
+
if (!emblaApi)
|
|
122
|
+
return undefined;
|
|
123
|
+
refresh();
|
|
124
|
+
emblaApi.on('reInit', refresh);
|
|
125
|
+
emblaApi.on('select', select);
|
|
126
|
+
return () => {
|
|
127
|
+
emblaApi.off('reInit', refresh);
|
|
128
|
+
emblaApi.off('select', select);
|
|
129
|
+
};
|
|
130
|
+
}, [emblaApi, refresh, select]);
|
|
131
|
+
useEffect(() => {
|
|
132
|
+
if (controlled || count === 0)
|
|
133
|
+
return;
|
|
134
|
+
setUncontrolledIndex(index => clampIndex(index, count));
|
|
135
|
+
}, [controlled, count]);
|
|
136
|
+
useEffect(() => {
|
|
137
|
+
if (!emblaApi || count === 0 || selectedIndex === activeIndex)
|
|
138
|
+
return;
|
|
139
|
+
emblaApi.scrollTo(activeIndex, reducedMotion);
|
|
140
|
+
}, [activeIndex, count, emblaApi, reducedMotion, selectedIndex]);
|
|
141
|
+
useEffect(() => {
|
|
142
|
+
const root = rootRef.current;
|
|
143
|
+
if (dir || !root)
|
|
144
|
+
return undefined;
|
|
145
|
+
const update = () => setInferredDir(getComputedStyle(root).direction === 'rtl' ? 'rtl' : 'ltr');
|
|
146
|
+
update();
|
|
147
|
+
const observer = new MutationObserver(update);
|
|
148
|
+
for (let node = root; node; node = node.parentElement) {
|
|
149
|
+
observer.observe(node, { attributes: true, attributeFilter: ['class', 'dir', 'style'] });
|
|
150
|
+
}
|
|
151
|
+
return () => observer.disconnect();
|
|
152
|
+
}, [dir]);
|
|
153
|
+
const pause = useCallback(() => {
|
|
154
|
+
autoplayPlugin.stop();
|
|
155
|
+
setPlaying(false);
|
|
156
|
+
}, [autoplayPlugin]);
|
|
157
|
+
const play = useCallback((explicit = false) => {
|
|
158
|
+
const blockedByHover = autoplayOptions.pauseOnHover !== false && hoveredRef.current;
|
|
159
|
+
const blockedByFocus = autoplayOptions.pauseOnFocus !== false && focusedRef.current;
|
|
160
|
+
if (!autoplayAllowed || (!explicit && (blockedByHover || blockedByFocus)))
|
|
161
|
+
return;
|
|
162
|
+
autoplayPlugin.play();
|
|
163
|
+
setPlaying(true);
|
|
164
|
+
}, [autoplayAllowed, autoplayOptions.pauseOnFocus, autoplayOptions.pauseOnHover, autoplayPlugin]);
|
|
165
|
+
useEffect(() => {
|
|
166
|
+
if (!emblaApi || !autoplayEnabled) {
|
|
167
|
+
autoplayPlugin.stop();
|
|
168
|
+
setPlaying(false);
|
|
169
|
+
return undefined;
|
|
170
|
+
}
|
|
171
|
+
const updatePlaying = () => setPlaying(autoplayPlugin.isPlaying());
|
|
172
|
+
const stopForInteraction = () => {
|
|
173
|
+
if (!stopOnInteraction)
|
|
174
|
+
return;
|
|
175
|
+
userPausedRef.current = true;
|
|
176
|
+
pause();
|
|
177
|
+
};
|
|
178
|
+
emblaApi.on('autoplay:play', updatePlaying);
|
|
179
|
+
emblaApi.on('autoplay:stop', updatePlaying);
|
|
180
|
+
emblaApi.on('pointerDown', stopForInteraction);
|
|
181
|
+
if (autoplayAllowed && !userPausedRef.current)
|
|
182
|
+
play();
|
|
183
|
+
else
|
|
184
|
+
pause();
|
|
185
|
+
return () => {
|
|
186
|
+
emblaApi.off('autoplay:play', updatePlaying);
|
|
187
|
+
emblaApi.off('autoplay:stop', updatePlaying);
|
|
188
|
+
emblaApi.off('pointerDown', stopForInteraction);
|
|
189
|
+
autoplayPlugin.stop();
|
|
190
|
+
};
|
|
191
|
+
}, [autoplayAllowed, autoplayEnabled, autoplayPlugin, emblaApi, pause, play, stopOnInteraction]);
|
|
192
|
+
const stopForControlInteraction = useCallback(() => {
|
|
193
|
+
if (!autoplayEnabled || !stopOnInteraction)
|
|
194
|
+
return;
|
|
195
|
+
userPausedRef.current = true;
|
|
196
|
+
pause();
|
|
197
|
+
}, [autoplayEnabled, pause, stopOnInteraction]);
|
|
198
|
+
const previous = useCallback(() => {
|
|
199
|
+
stopForControlInteraction();
|
|
200
|
+
emblaApi?.scrollPrev(reducedMotion);
|
|
201
|
+
}, [emblaApi, reducedMotion, stopForControlInteraction]);
|
|
202
|
+
const next = useCallback(() => {
|
|
203
|
+
stopForControlInteraction();
|
|
204
|
+
emblaApi?.scrollNext(reducedMotion);
|
|
205
|
+
}, [emblaApi, reducedMotion, stopForControlInteraction]);
|
|
206
|
+
const scrollTo = useCallback((index) => {
|
|
207
|
+
stopForControlInteraction();
|
|
208
|
+
emblaApi?.scrollTo(clampIndex(index, selectionCount), reducedMotion);
|
|
209
|
+
}, [emblaApi, reducedMotion, selectionCount, stopForControlInteraction]);
|
|
210
|
+
useImperativeHandle(forwardedRef, () => ({ root: rootRef.current, previous, next, scrollTo, play: () => play(true), pause }), [next, pause, play, previous, scrollTo]);
|
|
211
|
+
const resumeAfterTransientPause = () => {
|
|
212
|
+
const blockedByHover = autoplayOptions.pauseOnHover !== false && hoveredRef.current;
|
|
213
|
+
const blockedByFocus = autoplayOptions.pauseOnFocus !== false && focusedRef.current;
|
|
214
|
+
if (!userPausedRef.current && autoplayAllowed && !blockedByHover && !blockedByFocus)
|
|
215
|
+
play();
|
|
216
|
+
};
|
|
217
|
+
const handleKeyDown = (event) => {
|
|
218
|
+
onKeyDown?.(event);
|
|
219
|
+
if (event.defaultPrevented || event.target !== event.currentTarget || disabled || count < 2)
|
|
220
|
+
return;
|
|
221
|
+
if (event.key === 'ArrowLeft') {
|
|
222
|
+
event.preventDefault();
|
|
223
|
+
resolvedDir === 'rtl' ? next() : previous();
|
|
224
|
+
}
|
|
225
|
+
else if (event.key === 'ArrowRight') {
|
|
226
|
+
event.preventDefault();
|
|
227
|
+
resolvedDir === 'rtl' ? previous() : next();
|
|
228
|
+
}
|
|
229
|
+
else if (event.key === 'Home') {
|
|
230
|
+
event.preventDefault();
|
|
231
|
+
scrollTo(0);
|
|
232
|
+
}
|
|
233
|
+
else if (event.key === 'End') {
|
|
234
|
+
event.preventDefault();
|
|
235
|
+
scrollTo(count - 1);
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
const handlePointerEnter = (event) => {
|
|
239
|
+
onPointerEnter?.(event);
|
|
240
|
+
hoveredRef.current = true;
|
|
241
|
+
if (autoplayOptions.pauseOnHover !== false)
|
|
242
|
+
pause();
|
|
243
|
+
};
|
|
244
|
+
const handlePointerLeave = (event) => {
|
|
245
|
+
onPointerLeave?.(event);
|
|
246
|
+
hoveredRef.current = false;
|
|
247
|
+
resumeAfterTransientPause();
|
|
248
|
+
};
|
|
249
|
+
const handleFocus = (event) => {
|
|
250
|
+
onFocusCapture?.(event);
|
|
251
|
+
focusedRef.current = true;
|
|
252
|
+
if (autoplayControlPointerRef.current) {
|
|
253
|
+
autoplayControlPointerRef.current = false;
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
if (autoplayOptions.pauseOnFocus !== false)
|
|
257
|
+
pause();
|
|
258
|
+
};
|
|
259
|
+
const handleBlur = (event) => {
|
|
260
|
+
onBlurCapture?.(event);
|
|
261
|
+
if (event.currentTarget.contains(event.relatedTarget))
|
|
262
|
+
return;
|
|
263
|
+
focusedRef.current = false;
|
|
264
|
+
resumeAfterTransientPause();
|
|
265
|
+
};
|
|
266
|
+
const toggleAutoplay = () => {
|
|
267
|
+
if (playing) {
|
|
268
|
+
userPausedRef.current = true;
|
|
269
|
+
pause();
|
|
270
|
+
}
|
|
271
|
+
else {
|
|
272
|
+
userPausedRef.current = false;
|
|
273
|
+
play(true);
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
const rootStyle = {
|
|
277
|
+
...style,
|
|
278
|
+
'--inkra-carousel-gap': `${resolvedGap}px`,
|
|
279
|
+
'--inkra-carousel-visible-base': visible.base,
|
|
280
|
+
'--inkra-carousel-visible-sm': visible.sm,
|
|
281
|
+
'--inkra-carousel-visible-md': visible.md,
|
|
282
|
+
'--inkra-carousel-visible-lg': visible.lg,
|
|
283
|
+
};
|
|
284
|
+
const slideLabel = labels?.slide ?? ((index, total) => `Slide ${index} of ${total}`);
|
|
285
|
+
const pageLabel = labels?.page ?? ((index, total) => `Go to page ${index} of ${total}`);
|
|
286
|
+
const statusLabel = labels?.status ?? ((index, total) => `Slide ${index} of ${total}`);
|
|
287
|
+
const showNavigation = controls && count > 1;
|
|
288
|
+
const showPagination = pagination && scrollSnaps.length > 1;
|
|
289
|
+
return _jsxs("div", { ...props, "aria-label": ariaLabel, "aria-roledescription": "carousel", className: ['inkra-carousel', className].filter(Boolean).join(' '), "data-autoplay": autoplayEnabled || undefined, "data-disabled": disabled || undefined, "data-playing": playing || undefined, dir: resolvedDir, onBlurCapture: handleBlur, onFocusCapture: handleFocus, onKeyDown: handleKeyDown, onPointerEnter: handlePointerEnter, onPointerLeave: handlePointerLeave, ref: rootRef, role: role, style: rootStyle, tabIndex: !disabled && count > 1 ? (tabIndex ?? 0) : tabIndex, children: [count === 0 ? _jsx("div", { className: "inkra-carousel__empty", children: emptyState ?? labels?.empty ?? 'No items' }) : _jsxs(_Fragment, { children: [_jsx("div", { className: "inkra-carousel__viewport", ref: viewportRef, children: _jsx("div", { className: "inkra-carousel__container", children: slideItems.map((item, index) => _jsx("div", { "aria-label": slideLabel(index + 1, count), "aria-roledescription": "slide", className: "inkra-carousel__slide", "data-current": index === activeIndex || undefined, role: "group", children: item }, item.key ?? index)) }) }), (showNavigation || showPagination || autoplayEnabled) && _jsxs("div", { className: "inkra-carousel__toolbar", children: [showNavigation && _jsxs("div", { className: "inkra-carousel__navigation", children: [_jsx(Button, { "aria-label": labels?.previous ?? 'Previous slide', disabled: disabled || !canScrollPrevious, onClick: previous, size: "icon", variant: "outline", children: _jsx(PencilIcon, { decorative: true, paths: resolvedDir === 'rtl' ? nextPaths : previousPaths, seed: `${generatedId}-previous`, size: 20, sourceSize: 24 }) }), _jsx(Button, { "aria-label": labels?.next ?? 'Next slide', disabled: disabled || !canScrollNext, onClick: next, size: "icon", variant: "outline", children: _jsx(PencilIcon, { decorative: true, paths: resolvedDir === 'rtl' ? previousPaths : nextPaths, seed: `${generatedId}-next`, size: 20, sourceSize: 24 }) })] }), showPagination && _jsx("div", { "aria-label": labels?.pages ?? 'Carousel pages', className: "inkra-carousel__pagination", role: "group", children: scrollSnaps.map((_, index) => _jsx("button", { "aria-current": index === selectedIndex ? 'true' : undefined, "aria-label": pageLabel(index + 1, scrollSnaps.length), className: "inkra-carousel__page", disabled: disabled, onClick: () => scrollTo(index), type: "button", children: _jsx(PencilSurface, { as: "span", "aria-hidden": "true", className: "inkra-carousel__page-mark", radius: 6, seed: `${generatedId}-page-${index}`, variant: index === selectedIndex ? 'selection' : 'outline' }) }, index)) }), autoplayEnabled && _jsx(Button, { "aria-label": playing ? (labels?.pause ?? 'Pause autoplay') : (labels?.play ?? 'Play autoplay'), disabled: !autoplayAllowed, onClick: toggleAutoplay, onPointerDown: () => { autoplayControlPointerRef.current = true; }, size: "icon", variant: "ghost", children: _jsx(PencilIcon, { decorative: true, paths: playing ? pausePaths : playPaths, seed: `${generatedId}-autoplay`, size: 20, sourceSize: 24 }) })] })] }), count > 0 && _jsx("span", { "aria-atomic": "true", "aria-live": playing ? 'off' : 'polite', className: "inkra-carousel__status", children: statusLabel(activeIndex + 1, count) })] });
|
|
290
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { FieldsetHTMLAttributes, ImgHTMLAttributes, InputHTMLAttributes, ReactNode } from 'react';
|
|
2
|
+
export interface ChoiceGridImage extends Omit<ImgHTMLAttributes<HTMLImageElement>, 'alt' | 'children'> {
|
|
3
|
+
/** Choice images are decorative; the option label supplies the accessible name. */
|
|
4
|
+
alt?: '';
|
|
5
|
+
}
|
|
6
|
+
export type ChoiceGridColumns = 1 | 2 | 3 | 4;
|
|
7
|
+
interface ChoiceGridCommonProps extends Omit<FieldsetHTMLAttributes<HTMLFieldSetElement>, 'defaultValue' | 'onChange'> {
|
|
8
|
+
name: string;
|
|
9
|
+
label: ReactNode;
|
|
10
|
+
description?: ReactNode;
|
|
11
|
+
error?: ReactNode;
|
|
12
|
+
columns?: ChoiceGridColumns;
|
|
13
|
+
minItemWidth?: string;
|
|
14
|
+
}
|
|
15
|
+
export interface SingleChoiceGridProps extends ChoiceGridCommonProps {
|
|
16
|
+
type?: 'single';
|
|
17
|
+
value?: string;
|
|
18
|
+
defaultValue?: string;
|
|
19
|
+
onValueChange?: (value: string) => void;
|
|
20
|
+
}
|
|
21
|
+
export interface MultipleChoiceGridProps extends ChoiceGridCommonProps {
|
|
22
|
+
type: 'multiple';
|
|
23
|
+
values?: readonly string[];
|
|
24
|
+
defaultValues?: readonly string[];
|
|
25
|
+
onValuesChange?: (values: string[]) => void;
|
|
26
|
+
}
|
|
27
|
+
export type ChoiceGridProps = SingleChoiceGridProps | MultipleChoiceGridProps;
|
|
28
|
+
export interface ChoiceGridOptionProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'checked' | 'children' | 'defaultChecked' | 'name' | 'type'> {
|
|
29
|
+
value: string;
|
|
30
|
+
label: ReactNode;
|
|
31
|
+
description?: ReactNode;
|
|
32
|
+
icon?: ReactNode;
|
|
33
|
+
image?: ChoiceGridImage;
|
|
34
|
+
seed?: string | number;
|
|
35
|
+
}
|
|
36
|
+
export declare const ChoiceGrid: import("react").ForwardRefExoticComponent<ChoiceGridProps & import("react").RefAttributes<HTMLFieldSetElement>>;
|
|
37
|
+
export declare const ChoiceGridOption: import("react").ForwardRefExoticComponent<ChoiceGridOptionProps & import("react").RefAttributes<HTMLInputElement>>;
|
|
38
|
+
export {};
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { createContext, forwardRef, useContext, useId, useImperativeHandle, useRef, } from 'react';
|
|
3
|
+
import { PencilSurface } from '../PencilSurface.js';
|
|
4
|
+
import { PencilText } from '../PencilText.js';
|
|
5
|
+
import { Checkbox } from '../selection/Checkbox.js';
|
|
6
|
+
import { RadioGroup, RadioGroupItem } from '../selection/RadioGroup.js';
|
|
7
|
+
const classes = (...values) => values.filter(Boolean).join(' ');
|
|
8
|
+
function mergeIds(...values) {
|
|
9
|
+
const merged = Array.from(new Set(values.flatMap(value => value?.split(/\s+/).filter(Boolean) ?? [])));
|
|
10
|
+
return merged.length ? merged.join(' ') : undefined;
|
|
11
|
+
}
|
|
12
|
+
const ChoiceGridContext = createContext(null);
|
|
13
|
+
function GroupMessages({ description, descriptionId, error, errorId }) {
|
|
14
|
+
return description == null && error == null ? null : (_jsxs("div", { className: "inkra-choice-grid__messages", children: [description == null ? null : (_jsx("div", { className: "inkra-choice-grid__description", id: descriptionId, children: typeof description === 'string'
|
|
15
|
+
? _jsx(PencilText, { seed: `${descriptionId}-text`, children: description })
|
|
16
|
+
: description })), error == null ? null : (_jsx("div", { className: "inkra-choice-grid__error", id: errorId, children: typeof error === 'string'
|
|
17
|
+
? _jsx(PencilText, { seed: `${errorId}-text`, children: error })
|
|
18
|
+
: error }))] }));
|
|
19
|
+
}
|
|
20
|
+
function gridStyle(style, columns, minItemWidth) {
|
|
21
|
+
return {
|
|
22
|
+
...style,
|
|
23
|
+
'--inkra-choice-grid-columns': columns,
|
|
24
|
+
'--inkra-choice-grid-min-item': minItemWidth,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
export const ChoiceGrid = forwardRef(function ChoiceGrid(props, forwardedRef) {
|
|
28
|
+
const { 'aria-describedby': ariaDescribedBy, 'aria-errormessage': ariaErrorMessage, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, children, className, columns, description, disabled = false, error, form, label, minItemWidth = '13rem', name, style, type = 'single', ...rest } = props;
|
|
29
|
+
const generatedId = useId();
|
|
30
|
+
const descriptionId = description == null ? undefined : `inkra-choice-grid-${generatedId}-description`;
|
|
31
|
+
const errorId = error == null ? undefined : `inkra-choice-grid-${generatedId}-error`;
|
|
32
|
+
const describedBy = mergeIds(ariaDescribedBy, descriptionId, errorId);
|
|
33
|
+
const errorMessage = mergeIds(ariaErrorMessage, errorId);
|
|
34
|
+
const invalid = Boolean(error) || rest['aria-invalid'] === true || rest['aria-invalid'] === 'true';
|
|
35
|
+
const rootRef = useRef(null);
|
|
36
|
+
useImperativeHandle(forwardedRef, () => rootRef.current);
|
|
37
|
+
if (type === 'single') {
|
|
38
|
+
const single = props;
|
|
39
|
+
const { defaultValue: _defaultValue, onValueChange: _onValueChange, value: _value, ...singleFieldsetProps } = rest;
|
|
40
|
+
return (_jsxs("div", { className: classes('inkra-choice-grid', className), "data-columns": columns, "data-disabled": disabled || undefined, "data-invalid": invalid || undefined, "data-type": "single", style: gridStyle(style, columns, minItemWidth), children: [_jsx(ChoiceGridContext.Provider, { value: { disabled, form, mode: 'single', name }, children: _jsx(RadioGroup, { ...singleFieldsetProps, "aria-describedby": describedBy, "aria-errormessage": errorMessage, "aria-invalid": invalid || undefined, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, defaultValue: single.defaultValue, disabled: disabled, form: form, label: label, name: name, onValueChange: single.onValueChange, ref: rootRef, value: single.value, children: children }) }), _jsx(GroupMessages, { description: description, descriptionId: descriptionId, error: error, errorId: errorId })] }));
|
|
41
|
+
}
|
|
42
|
+
const multiple = props;
|
|
43
|
+
const { defaultValues: _defaultValues, onValuesChange: _onValuesChange, values: _values, ...multipleFieldsetProps } = rest;
|
|
44
|
+
const onMultipleChange = (optionValue, checked) => {
|
|
45
|
+
if (!multiple.onValuesChange)
|
|
46
|
+
return;
|
|
47
|
+
const selected = Array.from(rootRef.current?.querySelectorAll('input[type="checkbox"][data-choice-grid-value]') ?? []).filter(input => input.value === optionValue ? checked : input.checked)
|
|
48
|
+
.map(input => input.value);
|
|
49
|
+
multiple.onValuesChange(selected);
|
|
50
|
+
};
|
|
51
|
+
const context = {
|
|
52
|
+
defaultValues: multiple.defaultValues,
|
|
53
|
+
disabled,
|
|
54
|
+
form,
|
|
55
|
+
mode: 'multiple',
|
|
56
|
+
name,
|
|
57
|
+
onMultipleChange,
|
|
58
|
+
values: multiple.values,
|
|
59
|
+
};
|
|
60
|
+
return (_jsxs("fieldset", { ...multipleFieldsetProps, "aria-describedby": describedBy, "aria-errormessage": errorMessage, "aria-invalid": invalid || undefined, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, className: classes('inkra-choice-grid', className), "data-columns": columns, "data-disabled": disabled || undefined, "data-invalid": invalid || undefined, "data-type": "multiple", disabled: disabled, form: form, ref: rootRef, style: gridStyle(style, columns, minItemWidth), children: [label == null ? null : typeof label === 'string' ? (_jsx(PencilText, { as: "legend", className: "inkra-choice-grid__legend", seed: `inkra-choice-grid-${generatedId}-label`, children: label })) : _jsx("legend", { className: "inkra-choice-grid__legend", children: label }), _jsx(ChoiceGridContext.Provider, { value: context, children: _jsx("div", { className: "inkra-choice-grid__items", children: children }) }), _jsx(GroupMessages, { description: description, descriptionId: descriptionId, error: error, errorId: errorId })] }));
|
|
61
|
+
});
|
|
62
|
+
function OptionMedia({ icon, image }) {
|
|
63
|
+
if (image) {
|
|
64
|
+
return (_jsx("span", { "aria-hidden": "true", className: "inkra-choice-grid__media", "data-kind": "image", children: _jsx("img", { ...image, alt: "" }) }));
|
|
65
|
+
}
|
|
66
|
+
if (icon != null) {
|
|
67
|
+
return _jsx("span", { "aria-hidden": "true", className: "inkra-choice-grid__media", "data-kind": "icon", children: icon });
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
export const ChoiceGridOption = forwardRef(function ChoiceGridOption({ className, description, disabled = false, form, icon, id, image, label, onChange, required = false, seed, value, ...props }, ref) {
|
|
72
|
+
const context = useContext(ChoiceGridContext);
|
|
73
|
+
if (!context)
|
|
74
|
+
throw new Error('ChoiceGridOption must be rendered inside ChoiceGrid');
|
|
75
|
+
const generatedId = useId();
|
|
76
|
+
const inputId = id ?? `inkra-choice-grid-option-${generatedId}`;
|
|
77
|
+
const resolvedSeed = seed ?? inputId;
|
|
78
|
+
const itemDisabled = context.disabled || disabled;
|
|
79
|
+
const content = (_jsxs(_Fragment, { children: [_jsx(OptionMedia, { icon: icon, image: image }), _jsx("span", { className: "inkra-choice-grid__option-label", children: label })] }));
|
|
80
|
+
const control = context.mode === 'single' ? (_jsx(RadioGroupItem, { ...props, className: "inkra-choice-grid__control", description: description, disabled: itemDisabled, form: form ?? context.form, id: inputId, label: content, onChange: onChange, ref: ref, required: required, seed: resolvedSeed, value: value })) : (_jsx(Checkbox, { ...props, checked: context.values === undefined ? undefined : context.values.includes(value), className: "inkra-choice-grid__control", "data-choice-grid-value": "", defaultChecked: context.values === undefined ? context.defaultValues?.includes(value) : undefined, description: description, disabled: itemDisabled, form: form ?? context.form, id: inputId, label: content, name: context.name, onChange: (event) => {
|
|
81
|
+
onChange?.(event);
|
|
82
|
+
context.onMultipleChange?.(value, event.currentTarget.checked);
|
|
83
|
+
}, ref: ref, required: required, seed: resolvedSeed, value: value }));
|
|
84
|
+
return (_jsxs(PencilSurface, { as: "div", className: classes('inkra-choice-grid__option', className), "data-disabled": itemDisabled || undefined, radius: 8, seed: `${resolvedSeed}-paper`, variant: "paper", children: [_jsx(PencilSurface, { "aria-hidden": "true", as: "span", className: "inkra-choice-grid__selection", radius: 8, seed: `${resolvedSeed}-selection`, variant: "selection" }), control, _jsx(PencilSurface, { "aria-hidden": "true", as: "span", className: "inkra-choice-grid__outline", radius: 8, seed: `${resolvedSeed}-outline`, variant: "outline" })] }));
|
|
85
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export declare const DEFAULT_COLOR_VALUE = "#4b96aaff";
|
|
2
|
+
export interface ColorChannels {
|
|
3
|
+
red: number;
|
|
4
|
+
green: number;
|
|
5
|
+
blue: number;
|
|
6
|
+
alpha: number;
|
|
7
|
+
}
|
|
8
|
+
export type ColorValue = `#${string}`;
|
|
9
|
+
export declare function parseColorValue(input: string): ColorChannels | null;
|
|
10
|
+
export declare function formatColorValue(channels: ColorChannels): ColorValue;
|
|
11
|
+
export declare function normalizeColorValue(input: string): ColorValue | null;
|
|
12
|
+
export declare function colorRgbValue(input: string): `#${string}` | null;
|
|
13
|
+
export declare function colorAlphaPercent(input: string): number;
|
|
14
|
+
export declare function withColorAlpha(input: string, percentage: number): ColorValue | null;
|
|
15
|
+
export declare function withColorRgb(input: string, rgb: string): ColorValue | null;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export const DEFAULT_COLOR_VALUE = '#4b96aaff';
|
|
2
|
+
const HEX_COLOR = /^#([\da-f]{3}|[\da-f]{4}|[\da-f]{6}|[\da-f]{8})$/i;
|
|
3
|
+
function expandHex(value) {
|
|
4
|
+
return value.length <= 4
|
|
5
|
+
? [...value].map(character => `${character}${character}`).join('')
|
|
6
|
+
: value;
|
|
7
|
+
}
|
|
8
|
+
export function parseColorValue(input) {
|
|
9
|
+
const value = input.trim();
|
|
10
|
+
const match = HEX_COLOR.exec(value);
|
|
11
|
+
if (!match)
|
|
12
|
+
return null;
|
|
13
|
+
const expanded = expandHex(match[1]);
|
|
14
|
+
const opaque = expanded.length === 6;
|
|
15
|
+
return {
|
|
16
|
+
red: Number.parseInt(expanded.slice(0, 2), 16),
|
|
17
|
+
green: Number.parseInt(expanded.slice(2, 4), 16),
|
|
18
|
+
blue: Number.parseInt(expanded.slice(4, 6), 16),
|
|
19
|
+
alpha: opaque ? 255 : Number.parseInt(expanded.slice(6, 8), 16),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
function channelToHex(channel) {
|
|
23
|
+
return Math.min(255, Math.max(0, Math.round(channel))).toString(16).padStart(2, '0');
|
|
24
|
+
}
|
|
25
|
+
export function formatColorValue(channels) {
|
|
26
|
+
return `#${channelToHex(channels.red)}${channelToHex(channels.green)}${channelToHex(channels.blue)}${channelToHex(channels.alpha)}`;
|
|
27
|
+
}
|
|
28
|
+
export function normalizeColorValue(input) {
|
|
29
|
+
const channels = parseColorValue(input);
|
|
30
|
+
return channels ? formatColorValue(channels) : null;
|
|
31
|
+
}
|
|
32
|
+
export function colorRgbValue(input) {
|
|
33
|
+
const channels = parseColorValue(input);
|
|
34
|
+
return channels ? `#${channelToHex(channels.red)}${channelToHex(channels.green)}${channelToHex(channels.blue)}` : null;
|
|
35
|
+
}
|
|
36
|
+
export function colorAlphaPercent(input) {
|
|
37
|
+
const channels = parseColorValue(input) ?? parseColorValue(DEFAULT_COLOR_VALUE);
|
|
38
|
+
return Math.round(channels.alpha / 255 * 100);
|
|
39
|
+
}
|
|
40
|
+
export function withColorAlpha(input, percentage) {
|
|
41
|
+
const channels = parseColorValue(input);
|
|
42
|
+
if (!channels)
|
|
43
|
+
return null;
|
|
44
|
+
return formatColorValue({
|
|
45
|
+
...channels,
|
|
46
|
+
alpha: Math.round(Math.min(100, Math.max(0, percentage)) / 100 * 255),
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
export function withColorRgb(input, rgb) {
|
|
50
|
+
const current = parseColorValue(input);
|
|
51
|
+
const next = parseColorValue(rgb);
|
|
52
|
+
if (!current || !next)
|
|
53
|
+
return null;
|
|
54
|
+
return formatColorValue({ ...next, alpha: current.alpha });
|
|
55
|
+
}
|