@beydesign/storybook 0.2.25 → 0.2.27

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/dist/App.d.ts CHANGED
@@ -1,3 +1,3 @@
1
1
  import './App.css';
2
- declare function App(): import("react/jsx-runtime").JSX.Element;
2
+ declare function App(): import("react").JSX.Element;
3
3
  export default App;
@@ -0,0 +1,3 @@
1
+ import React from 'react';
2
+ import { DatePickerProps } from './types';
3
+ export declare const DatePicker: React.FC<DatePickerProps>;
@@ -0,0 +1,286 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useMemo, useRef, useState } from 'react';
3
+ import { Box, ClickAwayListener, Popper } from '@mui/material';
4
+ import { DatePickerSelectionMode } from './types';
5
+ import { Typography, TypographySize, TypographyWeight } from '../Typography';
6
+ import { getIconComponent } from '../../utils';
7
+ // ---------------------------------------------------------------------------
8
+ // Design tokens (mapped from the Figma "Date picker" + "Calendar cell" nodes)
9
+ // ---------------------------------------------------------------------------
10
+ const COLOR = {
11
+ title: 'var(--color-text-text-title)',
12
+ onAccent: 'var(--color-text-text-on-accent-color)',
13
+ inactive: 'var(--color-text-text-inactive)',
14
+ placeholder: 'var(--color-text-text-placeholder)',
15
+ clicable: 'var(--color-text-text-clicable)',
16
+ accent: 'var(--color-text-text-accent)',
17
+ brand: 'var(--color-background-bg-brand-primary)',
18
+ brandHover: 'var(--color-background-bg-brand-primary-hover)',
19
+ brandTransparent: 'var(--color-background-bg-brand-transparent)',
20
+ highlightHover: 'var(--color-background-bg-card-highlight-hover)',
21
+ componentDisabled: 'var(--color-background-bg-component-disabled)',
22
+ element: 'var(--color-background-bg-element)',
23
+ card: 'var(--color-background-bg-page-contrast)',
24
+ component: 'var(--color-background-bg-component)',
25
+ borderComponent: 'var(--color-border-border-component)',
26
+ borderBrand: 'var(--color-border-border-brand)',
27
+ fgSecondary: 'var(--color-foreground-fg-secondary)',
28
+ };
29
+ const RADIUS_FULL = 'var(--spacing-tokens-size-in-px-radius-radius-full)';
30
+ const RADIUS_XS = 'var(--spacing-tokens-size-in-px-radius-radius-xs)';
31
+ const SPACING_LG = 'var(--spacing-tokens-size-in-px-spacing-spacing-lg)';
32
+ const SPACING_MD = 'var(--spacing-tokens-size-in-px-spacing-spacing-md)';
33
+ const SPACING_XS = 'var(--spacing-tokens-size-in-px-spacing-spacing-xs)';
34
+ const WEEKDAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
35
+ // ---------------------------------------------------------------------------
36
+ // Pure date helpers (day-granularity, timezone-safe local dates)
37
+ // ---------------------------------------------------------------------------
38
+ const stripTime = (date) => new Date(date.getFullYear(), date.getMonth(), date.getDate());
39
+ const dayValue = (date) => stripTime(date).getTime();
40
+ const isSameDay = (a, b) => !!a && !!b && dayValue(a) === dayValue(b);
41
+ const isSameMonth = (a, b) => a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth();
42
+ const addMonths = (date, months) => new Date(date.getFullYear(), date.getMonth() + months, 1);
43
+ const addYears = (date, years) => new Date(date.getFullYear() + years, date.getMonth(), 1);
44
+ /** 42 days (6 weeks) covering `viewDate`'s month, Monday-first. */
45
+ const buildMonthGrid = (viewDate) => {
46
+ const first = new Date(viewDate.getFullYear(), viewDate.getMonth(), 1);
47
+ // getDay(): 0=Sun..6=Sat -> Monday-first offset
48
+ const leading = (first.getDay() + 6) % 7;
49
+ const gridStart = new Date(first.getFullYear(), first.getMonth(), 1 - leading);
50
+ return Array.from({ length: 42 }, (_, i) => new Date(gridStart.getFullYear(), gridStart.getMonth(), gridStart.getDate() + i));
51
+ };
52
+ const formatDate = (date) => date.toLocaleDateString('en-US', {
53
+ year: 'numeric',
54
+ month: 'short',
55
+ day: 'numeric',
56
+ });
57
+ const monthShortLabel = (monthIndex) => new Date(2000, monthIndex, 1).toLocaleDateString('en-US', {
58
+ month: 'short',
59
+ });
60
+ const CalendarCell = ({ label, onClick, disabled = false, muted = false, today = false, filled = false, rangeBg = false, roundLeft = false, roundRight = false, }) => {
61
+ const interactive = !!onClick && !disabled;
62
+ let outerBg = 'transparent';
63
+ if (disabled && (filled || rangeBg))
64
+ outerBg = COLOR.componentDisabled;
65
+ else if (rangeBg)
66
+ outerBg = COLOR.brandTransparent;
67
+ // Range background rounding. Non-range cells are fully rounded so the hover
68
+ // highlight reads as a circle.
69
+ let outerRadius = RADIUS_FULL;
70
+ if (rangeBg) {
71
+ const l = roundLeft ? RADIUS_FULL : '0';
72
+ const r = roundRight ? RADIUS_FULL : '0';
73
+ outerRadius = `${l} ${r} ${r} ${l}`;
74
+ }
75
+ let textColor = COLOR.title;
76
+ if (disabled)
77
+ textColor = COLOR.inactive;
78
+ else if (filled)
79
+ textColor = COLOR.onAccent;
80
+ else if (muted)
81
+ textColor = COLOR.inactive;
82
+ else if (today)
83
+ textColor = COLOR.clicable;
84
+ const innerBg = filled
85
+ ? disabled
86
+ ? COLOR.element
87
+ : COLOR.brand
88
+ : 'transparent';
89
+ const hoverSx = {};
90
+ if (interactive && filled) {
91
+ hoverSx['&:hover .datepicker-cell-inner'] = {
92
+ backgroundColor: COLOR.brandHover,
93
+ };
94
+ if (today)
95
+ hoverSx['&:hover .datepicker-cell-label'] = { color: COLOR.accent };
96
+ }
97
+ else if (interactive) {
98
+ hoverSx['&:hover'] = {
99
+ backgroundColor: COLOR.highlightHover,
100
+ ...(rangeBg ? {} : { borderRadius: RADIUS_FULL }),
101
+ };
102
+ if (today)
103
+ hoverSx['&:hover .datepicker-cell-label'] = { color: COLOR.accent };
104
+ }
105
+ return (_jsx(Box, { onClick: interactive ? onClick : undefined, display: "flex", alignItems: "center", justifyContent: "center", width: "40px", height: "40px", flexShrink: 0, bgcolor: outerBg, borderRadius: outerRadius, sx: {
106
+ boxSizing: 'border-box',
107
+ cursor: interactive ? 'pointer' : 'default',
108
+ ...hoverSx,
109
+ }, children: _jsx(Box, { className: "datepicker-cell-inner", display: "flex", alignItems: "center", justifyContent: "center", width: "28px", height: "28px", borderRadius: "14px", bgcolor: innerBg, children: _jsx(Typography, { className: "datepicker-cell-label", size: TypographySize.TextS, weight: TypographyWeight.Regular, color: textColor, textStyle: { lineHeight: '20px' }, children: label }) }) }));
110
+ };
111
+ const CalendarHeader = ({ title, onPrev, onNext, onTitleClick, }) => {
112
+ const caretButton = (icon, onClick) => (_jsx(Box, { onClick: onClick, display: "flex", alignItems: "center", justifyContent: "center", padding: "10px", borderRadius: RADIUS_XS, flexShrink: 0, sx: {
113
+ cursor: 'pointer',
114
+ filter: 'drop-shadow(0px 1px 1px rgba(25, 28, 31, 0.05))',
115
+ '&:hover': { backgroundColor: COLOR.highlightHover },
116
+ }, children: getIconComponent(icon, {
117
+ width: '20px',
118
+ height: '20px',
119
+ color: COLOR.title,
120
+ 'aria-hidden': 'true',
121
+ }) }));
122
+ return (_jsxs(Box, { display: "flex", alignItems: "center", justifyContent: "space-between", width: "100%", children: [caretButton('CaretLeft', onPrev), _jsx(Box, { onClick: onTitleClick, display: "flex", alignItems: "center", justifyContent: "center", flex: "1 0 0", minWidth: 0, borderRadius: RADIUS_XS, padding: `${SPACING_XS} ${SPACING_MD}`, sx: {
123
+ cursor: onTitleClick ? 'pointer' : 'default',
124
+ '&:hover': onTitleClick
125
+ ? { backgroundColor: COLOR.highlightHover }
126
+ : undefined,
127
+ }, children: _jsx(Typography, { size: TypographySize.HeadingS, weight: TypographyWeight.Medium, color: COLOR.title, textStyle: { textAlign: 'center' }, children: title }) }), caretButton('CaretRight', onNext)] }));
128
+ };
129
+ export const DatePicker = ({ label = 'Select date', placeholder = 'Select a date', disabled = false, required = false, showClear = true, minDate, maxDate, ...props }) => {
130
+ const [open, setOpen] = useState(false);
131
+ const [view, setView] = useState('date');
132
+ const anchorRef = useRef(null);
133
+ // The selection, expressed uniformly as a range internally.
134
+ const selection = useMemo(() => {
135
+ if (props.selectionMode === DatePickerSelectionMode.Single) {
136
+ return { start: props.value, end: props.value };
137
+ }
138
+ return props.value;
139
+ }, [props.selectionMode, props.value]);
140
+ const anchorDate = selection.start ?? new Date();
141
+ const [viewDate, setViewDate] = useState(new Date(anchorDate.getFullYear(), anchorDate.getMonth(), 1));
142
+ // Keep the visible month in sync with the selection whenever the popup opens.
143
+ const handleToggleOpen = () => {
144
+ if (disabled)
145
+ return;
146
+ if (!open) {
147
+ const base = selection.start ?? new Date();
148
+ setViewDate(new Date(base.getFullYear(), base.getMonth(), 1));
149
+ setView('date');
150
+ }
151
+ setOpen((prev) => !prev);
152
+ };
153
+ const isDisabledDay = (date) => (!!minDate && dayValue(date) < dayValue(minDate)) ||
154
+ (!!maxDate && dayValue(date) > dayValue(maxDate));
155
+ const commitSingle = (date) => {
156
+ if (props.selectionMode !== DatePickerSelectionMode.Single)
157
+ return;
158
+ props.onChange(date);
159
+ setOpen(false);
160
+ };
161
+ const commitRange = (date) => {
162
+ if (props.selectionMode !== DatePickerSelectionMode.Range)
163
+ return;
164
+ const { start, end } = props.value;
165
+ if (!start || (start && end)) {
166
+ // Start a fresh range.
167
+ props.onChange({ start: date, end: null });
168
+ return;
169
+ }
170
+ // Complete the range, ordering the two bounds.
171
+ if (dayValue(date) < dayValue(start)) {
172
+ props.onChange({ start: date, end: start });
173
+ }
174
+ else {
175
+ props.onChange({ start, end: date });
176
+ }
177
+ setOpen(false);
178
+ };
179
+ const handleDayClick = (date) => {
180
+ if (isDisabledDay(date))
181
+ return;
182
+ if (props.selectionMode === DatePickerSelectionMode.Single) {
183
+ commitSingle(date);
184
+ }
185
+ else {
186
+ commitRange(date);
187
+ }
188
+ };
189
+ const handleClear = (e) => {
190
+ e.stopPropagation();
191
+ if (props.selectionMode === DatePickerSelectionMode.Single) {
192
+ props.onChange(null);
193
+ }
194
+ else {
195
+ props.onChange({ start: null, end: null });
196
+ }
197
+ };
198
+ const displayValue = useMemo(() => {
199
+ const { start, end } = selection;
200
+ if (props.selectionMode === DatePickerSelectionMode.Single) {
201
+ return start ? formatDate(start) : '';
202
+ }
203
+ if (start && end)
204
+ return `${formatDate(start)} – ${formatDate(end)}`;
205
+ if (start)
206
+ return `${formatDate(start)} – …`;
207
+ return '';
208
+ }, [selection, props.selectionMode]);
209
+ const hasSelection = displayValue.length > 0;
210
+ // -------------------------------------------------------------------------
211
+ // Date grid
212
+ // -------------------------------------------------------------------------
213
+ const weeks = useMemo(() => {
214
+ const grid = buildMonthGrid(viewDate);
215
+ return Array.from({ length: 6 }, (_, w) => grid.slice(w * 7, w * 7 + 7));
216
+ }, [viewDate]);
217
+ const today = new Date();
218
+ const { start: selStart, end: selEnd } = selection;
219
+ const hasSpan = !!selStart && !!selEnd && !isSameDay(selStart, selEnd);
220
+ const isRangeBg = (date) => hasSpan &&
221
+ dayValue(date) >= dayValue(selStart) &&
222
+ dayValue(date) <= dayValue(selEnd);
223
+ const isFilled = (date) => isSameDay(date, selStart) || isSameDay(date, selEnd);
224
+ const renderDateView = () => (_jsxs(Box, { display: "flex", flexDirection: "column", gap: SPACING_LG, width: "280px", children: [_jsx(CalendarHeader, { title: viewDate.toLocaleDateString('en-US', {
225
+ month: 'long',
226
+ year: 'numeric',
227
+ }), onPrev: () => setViewDate(addMonths(viewDate, -1)), onNext: () => setViewDate(addMonths(viewDate, 1)), onTitleClick: () => setView('month') }), _jsxs(Box, { display: "flex", flexDirection: "column", gap: SPACING_XS, children: [_jsx(Box, { display: "flex", children: WEEKDAYS.map((day) => (_jsx(Box, { display: "flex", alignItems: "center", justifyContent: "center", width: "40px", height: "40px", flexShrink: 0, children: _jsx(Typography, { size: TypographySize.HeadingXS, weight: TypographyWeight.Medium, color: COLOR.title, children: day }) }, day))) }), weeks.map((week, wIdx) => (_jsx(Box, { display: "flex", children: week.map((date, dIdx) => {
228
+ const rangeBg = isRangeBg(date);
229
+ return (_jsx(CalendarCell, { label: String(date.getDate()), onClick: () => handleDayClick(date), disabled: isDisabledDay(date), muted: !isSameMonth(date, viewDate), today: isSameDay(date, today), filled: isFilled(date), rangeBg: rangeBg, roundLeft: rangeBg && (dIdx === 0 || !isRangeBg(week[dIdx - 1])), roundRight: rangeBg && (dIdx === 6 || !isRangeBg(week[dIdx + 1])) }, dIdx));
230
+ }) }, wIdx)))] })] }));
231
+ const renderMonthView = () => (_jsxs(Box, { display: "flex", flexDirection: "column", gap: SPACING_LG, width: "280px", children: [_jsx(CalendarHeader, { title: String(viewDate.getFullYear()), onPrev: () => setViewDate(addYears(viewDate, -1)), onNext: () => setViewDate(addYears(viewDate, 1)), onTitleClick: () => setView('year') }), _jsx(Box, { display: "flex", flexDirection: "column", justifyContent: "space-between", height: "260px", paddingY: SPACING_MD, children: Array.from({ length: 4 }, (_, row) => (_jsx(Box, { display: "flex", justifyContent: "space-between", children: Array.from({ length: 3 }, (_, col) => {
232
+ const monthIndex = row * 3 + col;
233
+ const monthStart = new Date(viewDate.getFullYear(), monthIndex, 1);
234
+ const monthEnd = new Date(viewDate.getFullYear(), monthIndex + 1, 0);
235
+ const monthDisabled = (!!minDate && dayValue(monthEnd) < dayValue(minDate)) ||
236
+ (!!maxDate && dayValue(monthStart) > dayValue(maxDate));
237
+ const selected = !!selStart &&
238
+ selStart.getFullYear() === viewDate.getFullYear() &&
239
+ selStart.getMonth() === monthIndex;
240
+ return (_jsx(CalendarCell, { label: monthShortLabel(monthIndex), onClick: () => {
241
+ setViewDate(monthStart);
242
+ setView('date');
243
+ }, disabled: monthDisabled, filled: selected }, col));
244
+ }) }, row))) })] }));
245
+ const renderYearView = () => {
246
+ const decadeStart = Math.floor(viewDate.getFullYear() / 10) * 10;
247
+ const years = Array.from({ length: 10 }, (_, i) => decadeStart + i);
248
+ return (_jsxs(Box, { display: "flex", flexDirection: "column", gap: SPACING_LG, width: "280px", children: [_jsx(CalendarHeader, { title: `${decadeStart}-${decadeStart + 9}`, onPrev: () => setViewDate(addYears(viewDate, -10)), onNext: () => setViewDate(addYears(viewDate, 10)) }), _jsx(Box, { display: "flex", flexWrap: "wrap", justifyContent: "space-between", rowGap: SPACING_LG, height: "260px", paddingY: SPACING_MD, alignContent: "space-between", children: years.map((year) => {
249
+ const yearDisabled = (!!minDate && year < minDate.getFullYear()) ||
250
+ (!!maxDate && year > maxDate.getFullYear());
251
+ const selected = !!selStart && selStart.getFullYear() === year;
252
+ return (_jsx(Box, { width: "33.333%", display: "flex", justifyContent: "center", children: _jsx(CalendarCell, { label: String(year), onClick: () => {
253
+ setViewDate(new Date(year, viewDate.getMonth(), 1));
254
+ setView('month');
255
+ }, disabled: yearDisabled, muted: yearDisabled, filled: selected }) }, year));
256
+ }) })] }));
257
+ };
258
+ return (_jsx(ClickAwayListener, { onClickAway: () => setOpen(false), children: _jsxs(Box, { display: "flex", flexDirection: "column", justifyContent: "center", gap: SPACING_MD, position: "relative", width: "100%", minWidth: "300px", children: [label && (_jsx(Typography, { size: TypographySize.TextS, weight: TypographyWeight.Regular, textStyle: { textAlign: 'left' }, required: required, children: label })), _jsxs(Box, { ref: anchorRef, display: "flex", flexDirection: "row", gap: SPACING_MD, height: "40px", padding: `0 ${SPACING_LG}`, alignItems: "center", justifyContent: "space-between", border: `1px solid ${open ? COLOR.borderBrand : COLOR.borderComponent}`, borderRadius: "4px", bgcolor: disabled ? COLOR.componentDisabled : COLOR.component, onClick: handleToggleOpen, tabIndex: disabled ? -1 : 0, "aria-haspopup": "dialog", "aria-expanded": open, "aria-disabled": disabled, role: "combobox", sx: {
259
+ boxSizing: 'border-box',
260
+ cursor: disabled ? 'default' : 'pointer',
261
+ }, children: [_jsxs(Box, { display: "flex", flexDirection: "row", alignItems: "center", gap: SPACING_MD, flexGrow: 1, minWidth: 0, overflow: "hidden", children: [_jsx(Box, { display: "flex", alignItems: "center", flexShrink: 0, children: getIconComponent('CalendarBlank', {
262
+ width: '20px',
263
+ height: '20px',
264
+ color: COLOR.fgSecondary,
265
+ 'aria-hidden': 'true',
266
+ }) }), _jsx(Typography, { size: TypographySize.TextS, weight: TypographyWeight.Regular, color: hasSelection ? COLOR.title : COLOR.placeholder, textStyle: {
267
+ flexGrow: 1,
268
+ minWidth: 0,
269
+ textAlign: 'left',
270
+ overflow: 'hidden',
271
+ textOverflow: 'ellipsis',
272
+ whiteSpace: 'nowrap',
273
+ maxWidth: '100%',
274
+ }, children: hasSelection ? displayValue : placeholder })] }), !disabled && showClear && hasSelection && (_jsx(Box, { display: "flex", alignItems: "center", flexShrink: 0, children: getIconComponent('X', {
275
+ width: '20px',
276
+ height: '20px',
277
+ color: COLOR.fgSecondary,
278
+ onClick: handleClear,
279
+ 'aria-label': 'Clear date',
280
+ role: 'button',
281
+ tabIndex: 0,
282
+ }) }))] }), _jsx(Popper, { open: open, anchorEl: anchorRef.current, placement: "bottom-start", style: { zIndex: 1300 }, children: _jsxs(Box, { bgcolor: COLOR.card, borderRadius: SPACING_MD, mt: "4px", paddingX: "24px", paddingY: "20px", width: "328px", role: "dialog", sx: {
283
+ boxSizing: 'border-box',
284
+ boxShadow: '0px 20px 24px -4px rgba(34, 7, 56, 0.08)',
285
+ }, children: [view === 'date' && renderDateView(), view === 'month' && renderMonthView(), view === 'year' && renderYearView()] }) })] }) }));
286
+ };
@@ -0,0 +1,11 @@
1
+ import type { Meta, StoryObj } from '@storybook/react';
2
+ import { DatePicker } from './DatePicker';
3
+ declare const meta: Meta<typeof DatePicker>;
4
+ export default meta;
5
+ type Story = StoryObj<typeof DatePicker>;
6
+ export declare const SingleSelect: Story;
7
+ export declare const PreselectedDate: Story;
8
+ export declare const RangeSelect: Story;
9
+ export declare const PreselectedRange: Story;
10
+ export declare const WithMinMax: Story;
11
+ export declare const Disabled: Story;
@@ -0,0 +1,157 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useState } from 'react';
3
+ import { DatePicker } from './DatePicker';
4
+ import { DatePickerSelectionMode } from './types';
5
+ const meta = {
6
+ title: 'Design System/Components/Form/DatePicker',
7
+ component: DatePicker,
8
+ parameters: {
9
+ layout: 'centered',
10
+ design: {
11
+ type: 'figspec',
12
+ url: 'https://www.figma.com/design/mIhjz2yJjcpLlIqw6oUivt/Beyond-Presence?node-id=4785-10092',
13
+ },
14
+ },
15
+ tags: ['autodocs'],
16
+ argTypes: {
17
+ selectionMode: {
18
+ control: 'select',
19
+ options: Object.values(DatePickerSelectionMode),
20
+ description: 'Whether the picker selects a single date or a date range',
21
+ table: {
22
+ type: { summary: 'DatePickerSelectionMode' },
23
+ defaultValue: { summary: DatePickerSelectionMode.Single },
24
+ },
25
+ },
26
+ value: {
27
+ control: false,
28
+ description: 'The selected value: a Date | null (Single) or a { start, end } range (Range)',
29
+ table: {
30
+ type: { summary: 'Date | null | DateRange' },
31
+ },
32
+ },
33
+ onChange: {
34
+ description: 'Called with the updated selection',
35
+ table: {
36
+ type: { summary: 'function' },
37
+ },
38
+ },
39
+ label: {
40
+ control: 'text',
41
+ description: 'The label rendered above the input',
42
+ table: {
43
+ type: { summary: 'string' },
44
+ },
45
+ },
46
+ placeholder: {
47
+ control: 'text',
48
+ description: 'Placeholder shown when no date is selected',
49
+ table: {
50
+ type: { summary: 'string' },
51
+ },
52
+ },
53
+ disabled: {
54
+ control: 'boolean',
55
+ description: 'Whether the date picker is disabled',
56
+ table: {
57
+ type: { summary: 'boolean' },
58
+ },
59
+ },
60
+ required: {
61
+ control: 'boolean',
62
+ description: 'Whether the field is required',
63
+ table: {
64
+ type: { summary: 'boolean' },
65
+ },
66
+ },
67
+ showClear: {
68
+ control: 'boolean',
69
+ description: 'Whether to show the clear button when a date is selected',
70
+ table: {
71
+ type: { summary: 'boolean' },
72
+ defaultValue: { summary: 'true' },
73
+ },
74
+ },
75
+ minDate: {
76
+ control: false,
77
+ description: 'Earliest selectable date (inclusive)',
78
+ table: {
79
+ type: { summary: 'Date' },
80
+ },
81
+ },
82
+ maxDate: {
83
+ control: false,
84
+ description: 'Latest selectable date (inclusive)',
85
+ table: {
86
+ type: { summary: 'Date' },
87
+ },
88
+ },
89
+ },
90
+ };
91
+ export default meta;
92
+ const InteractiveSingle = ({ initialValue, ...args }) => {
93
+ const [value, setValue] = useState(initialValue ?? null);
94
+ return (_jsx(DatePicker, { ...args, selectionMode: DatePickerSelectionMode.Single, value: value, onChange: setValue }));
95
+ };
96
+ const InteractiveRange = ({ initialValue, ...args }) => {
97
+ const [value, setValue] = useState(initialValue ?? { start: null, end: null });
98
+ return (_jsx(DatePicker, { ...args, selectionMode: DatePickerSelectionMode.Range, value: value, onChange: setValue }));
99
+ };
100
+ export const SingleSelect = {
101
+ render: (args) => _jsx(InteractiveSingle, { ...args }),
102
+ args: {
103
+ selectionMode: DatePickerSelectionMode.Single,
104
+ label: 'Date',
105
+ placeholder: 'Select a date',
106
+ showClear: true,
107
+ required: false,
108
+ disabled: false,
109
+ },
110
+ };
111
+ export const PreselectedDate = {
112
+ render: (args) => (_jsx(InteractiveSingle, { ...args, initialValue: new Date(2024, 7, 11) })),
113
+ args: {
114
+ selectionMode: DatePickerSelectionMode.Single,
115
+ label: 'Start date',
116
+ placeholder: 'Select a date',
117
+ showClear: true,
118
+ },
119
+ };
120
+ export const RangeSelect = {
121
+ render: (args) => _jsx(InteractiveRange, { ...args }),
122
+ args: {
123
+ selectionMode: DatePickerSelectionMode.Range,
124
+ label: 'Date range',
125
+ placeholder: 'Select a range',
126
+ showClear: true,
127
+ },
128
+ };
129
+ export const PreselectedRange = {
130
+ render: (args) => (_jsx(InteractiveRange, { ...args, initialValue: { start: new Date(2024, 7, 8), end: new Date(2024, 7, 11) } })),
131
+ args: {
132
+ selectionMode: DatePickerSelectionMode.Range,
133
+ label: 'Reporting period',
134
+ placeholder: 'Select a range',
135
+ showClear: true,
136
+ },
137
+ };
138
+ export const WithMinMax = {
139
+ render: (args) => (_jsx(InteractiveSingle, { ...args, initialValue: new Date(2024, 7, 15) })),
140
+ args: {
141
+ selectionMode: DatePickerSelectionMode.Single,
142
+ label: 'Bounded date',
143
+ placeholder: 'Select a date',
144
+ minDate: new Date(2024, 7, 5),
145
+ maxDate: new Date(2024, 7, 25),
146
+ showClear: true,
147
+ },
148
+ };
149
+ export const Disabled = {
150
+ render: (args) => (_jsx(InteractiveSingle, { ...args, initialValue: new Date(2024, 7, 11) })),
151
+ args: {
152
+ selectionMode: DatePickerSelectionMode.Single,
153
+ label: 'Date',
154
+ placeholder: 'Select a date',
155
+ disabled: true,
156
+ },
157
+ };
@@ -1,21 +1,44 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useCallback, useEffect, useMemo, useRef, useState, } from 'react';
3
3
  import { DropdownType } from './types';
4
- import { Box, ClickAwayListener, Popper } from '@mui/material';
4
+ import { Box, CircularProgress, ClickAwayListener, Popper, } from '@mui/material';
5
5
  import { Typography, TypographySize, TypographyWeight } from '../Typography';
6
6
  import { getIconComponent } from '../../utils';
7
7
  import { Checkbox } from './Checkbox';
8
- export const Dropdown = ({ disabled = false, placeholder = 'Select an option', label = 'Select', showLeadingIcon = false, leadingIcon = 'List', showClear = true, options, required = false, showCheckboxes = false, showTicks = true, ...props }) => {
8
+ import { Badge, BadgeColor, BadgeSize } from '../UI';
9
+ export const Dropdown = ({ disabled = false, placeholder = 'Select an option', label = 'Select', showLeadingIcon = false, leadingIcon = 'List', showClear = true, options, required = false, showCheckboxes = false, showTicks = true, onEndReached, isLoadingMore = false, ...props }) => {
9
10
  const [open, setOpen] = useState(false);
10
11
  const [highlightedIndex, setHighlightedIndex] = useState(-1);
11
12
  const anchorRef = useRef(null);
12
13
  const optionsContainerRef = useRef(null);
13
- const value = useMemo(() => {
14
- if (props.type === DropdownType.SingleSelect) {
15
- return props.value;
16
- }
17
- return Array.isArray(props.value) ? props.value.join(', ') : '';
18
- }, [props.type, props.value]);
14
+ // Normalize the two accepted option shapes (string[] and DropdownOption[])
15
+ // into a single canonical form so the rest of the component only deals with
16
+ // objects. A bare string maps to an option whose value equals its label.
17
+ const normalizedOptions = useMemo(() => options.map((option) => typeof option === 'string' ? { value: option, label: option } : option), [options]);
18
+ // The currently selected options, resolved from the selected value(s). When a
19
+ // selected value isn't present in the loaded options (e.g. an infinite list
20
+ // hasn't fetched it yet), fall back to an option whose label is the raw value
21
+ // so the trigger never goes blank.
22
+ const selectedOptions = useMemo(() => {
23
+ const selectedValues = props.type === DropdownType.SingleSelect
24
+ ? props.value
25
+ ? [props.value]
26
+ : []
27
+ : Array.isArray(props.value)
28
+ ? props.value
29
+ : [];
30
+ return selectedValues.map((val) => normalizedOptions.find((option) => option.value === val) ?? {
31
+ value: val,
32
+ label: val,
33
+ });
34
+ }, [props.type, props.value, normalizedOptions]);
35
+ // Whether any selected option carries an avatar or badge. When it does, the
36
+ // trigger renders the rich "Avatar leading" layout; otherwise it falls back to
37
+ // the plain joined-label text (preserving the original behavior).
38
+ const hasRichSelection = selectedOptions.some((option) => option.avatar || option.badge);
39
+ const hasSelection = selectedOptions.length > 0;
40
+ // Plain-text representation of the selection, used for the non-rich trigger.
41
+ const displayValue = useMemo(() => selectedOptions.map((option) => option.label).join(', '), [selectedOptions]);
19
42
  const handleOptionSelect = useCallback((option) => {
20
43
  if (props.type === DropdownType.SingleSelect) {
21
44
  props.onChange(option);
@@ -43,8 +66,8 @@ export const Dropdown = ({ disabled = false, placeholder = 'Select an option', l
43
66
  switch (e.key) {
44
67
  case 'Enter':
45
68
  e.preventDefault();
46
- if (open) {
47
- handleOptionSelect(options[highlightedIndex]);
69
+ if (open && normalizedOptions[highlightedIndex]) {
70
+ handleOptionSelect(normalizedOptions[highlightedIndex].value);
48
71
  }
49
72
  break;
50
73
  case ' ':
@@ -61,7 +84,7 @@ export const Dropdown = ({ disabled = false, placeholder = 'Select an option', l
61
84
  setOpen(true);
62
85
  }
63
86
  else {
64
- setHighlightedIndex((prev) => prev < options.length - 1 ? prev + 1 : 0);
87
+ setHighlightedIndex((prev) => prev < normalizedOptions.length - 1 ? prev + 1 : 0);
65
88
  }
66
89
  break;
67
90
  case 'ArrowUp':
@@ -70,7 +93,7 @@ export const Dropdown = ({ disabled = false, placeholder = 'Select an option', l
70
93
  setOpen(true);
71
94
  }
72
95
  else {
73
- setHighlightedIndex((prev) => prev > 0 ? prev - 1 : options.length - 1);
96
+ setHighlightedIndex((prev) => prev > 0 ? prev - 1 : normalizedOptions.length - 1);
74
97
  }
75
98
  break;
76
99
  case 'Tab':
@@ -86,16 +109,16 @@ export const Dropdown = ({ disabled = false, placeholder = 'Select an option', l
86
109
  if (open &&
87
110
  e.key === 'Enter' &&
88
111
  highlightedIndex >= 0 &&
89
- highlightedIndex < options.length &&
112
+ highlightedIndex < normalizedOptions.length &&
90
113
  !disabled) {
91
- handleOptionSelect(options[highlightedIndex]);
114
+ handleOptionSelect(normalizedOptions[highlightedIndex].value);
92
115
  }
93
116
  };
94
117
  window.addEventListener('keydown', handleKeyPress);
95
118
  return () => {
96
119
  window.removeEventListener('keydown', handleKeyPress);
97
120
  };
98
- }, [open, highlightedIndex, options, handleOptionSelect, disabled]);
121
+ }, [open, highlightedIndex, normalizedOptions, handleOptionSelect, disabled]);
99
122
  // Scroll to highlighted option
100
123
  useEffect(() => {
101
124
  if (open && highlightedIndex >= 0 && optionsContainerRef.current) {
@@ -105,34 +128,56 @@ export const Dropdown = ({ disabled = false, placeholder = 'Select an option', l
105
128
  }
106
129
  }
107
130
  }, [highlightedIndex, open]);
108
- const isOptionSelected = (option) => {
131
+ const isOptionSelected = (optionValue) => {
109
132
  if (props.type === DropdownType.SingleSelect) {
110
- return props.value === option;
133
+ return props.value === optionValue;
111
134
  }
112
- return Array.isArray(props.value) && props.value.includes(option);
135
+ return Array.isArray(props.value) && props.value.includes(optionValue);
113
136
  };
137
+ // Fire onEndReached once the user scrolls within `endReachedThreshold` pixels
138
+ // of the bottom. Suppressed while a load is already in flight so it doesn't
139
+ // spam the caller between the scroll and the parent setting isLoadingMore.
140
+ const handleOptionsScroll = useCallback(() => {
141
+ const container = optionsContainerRef.current;
142
+ if (!container || !onEndReached || isLoadingMore)
143
+ return;
144
+ const endReachedThreshold = 40;
145
+ const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
146
+ if (distanceFromBottom <= endReachedThreshold) {
147
+ onEndReached();
148
+ }
149
+ }, [onEndReached, isLoadingMore]);
114
150
  return (_jsx(ClickAwayListener, { onClickAway: () => setOpen(false), children: _jsxs(Box, { display: 'flex', flexDirection: 'column', justifyContent: 'center', gap: 'var(--spacing-tokens-size-in-px-spacing-spacing-md)', position: 'relative', width: "100%", minWidth: '300px', children: [label && (_jsx(Typography, { size: TypographySize.TextS, weight: TypographyWeight.Regular, textStyle: { textAlign: 'left' }, required: required, children: label })), _jsxs(Box, { ref: anchorRef, display: 'flex', flexDirection: 'row', gap: 'var(--spacing-tokens-size-in-px-spacing-spacing-md)', height: '40px', padding: '0 var(--spacing-tokens-size-in-px-spacing-spacing-lg)', alignItems: 'center', justifyContent: 'space-between', border: `1px solid ${open
115
- ? 'var(--color-border-border-component-active)'
151
+ ? 'var(--color-border-border-brand)'
116
152
  : 'var(--color-border-border-component)'}`, borderRadius: '4px', bgcolor: disabled
117
153
  ? 'var(--color-background-bg-component-disabled)'
118
- : 'var(--color-background-bg-component)', position: 'relative', onClick: () => !disabled && setOpen((prev) => !prev), onKeyDown: handleKeyDown, tabIndex: disabled ? -1 : 0, "aria-haspopup": "listbox", "aria-expanded": open, "aria-disabled": disabled, role: "combobox", sx: { boxSizing: 'border-box' }, children: [showLeadingIcon && (_jsx(Box, { display: "flex", alignItems: "center", flexShrink: 0, children: getIconComponent(leadingIcon, {
119
- width: '24px',
120
- height: '24px',
121
- color: 'var(--color-foreground-fg-secondary)',
122
- 'aria-hidden': 'true',
123
- }) })), _jsx(Typography, { size: TypographySize.TextS, weight: TypographyWeight.Regular, textStyle: {
124
- flexGrow: 1,
125
- minWidth: 0,
126
- textAlign: 'left',
127
- overflow: 'hidden',
128
- textOverflow: 'ellipsis',
129
- whiteSpace: 'nowrap',
130
- maxWidth: '100%',
131
- }, color: value
132
- ? 'var(--color-text-text-title)'
133
- : 'var(--color-text-text-placeholder)', children: value || placeholder }), _jsxs(Box, { display: "flex", alignItems: "center", gap: "var(--spacing-tokens-size-in-px-spacing-spacing-sm)", flexShrink: 0, children: [!disabled &&
154
+ : 'var(--color-background-bg-component)', position: 'relative', onClick: () => !disabled && setOpen((prev) => !prev), onKeyDown: handleKeyDown, tabIndex: disabled ? -1 : 0, "aria-haspopup": "listbox", "aria-expanded": open, "aria-disabled": disabled, role: "combobox", sx: { boxSizing: 'border-box' }, children: [_jsx(Box, { display: "flex", flexDirection: "row", alignItems: "center", gap: "var(--spacing-tokens-size-in-px-spacing-spacing-sm)", flexGrow: 1, minWidth: 0, overflow: "hidden", children: hasSelection && hasRichSelection ? (_jsxs(_Fragment, { children: [selectedOptions[0].avatar && (_jsx(Box, { component: "img", src: selectedOptions[0].avatar, alt: "", width: "24px", height: "24px", borderRadius: "var(--spacing-tokens-size-in-px-radius-radius-full)", flexShrink: 0, sx: {
155
+ objectFit: 'cover',
156
+ border: '0.5px solid var(--color-border-border-divider)',
157
+ } })), _jsx(Typography, { size: TypographySize.TextS, weight: TypographyWeight.Regular, color: "var(--color-text-text-title)", textStyle: {
158
+ minWidth: 0,
159
+ textAlign: 'left',
160
+ overflow: 'hidden',
161
+ textOverflow: 'ellipsis',
162
+ whiteSpace: 'nowrap',
163
+ }, children: selectedOptions[0].label }), selectedOptions[0].badge && (_jsx(Box, { flexShrink: 0, children: _jsx(Badge, { size: BadgeSize.xs, color: BadgeColor.Default, text: selectedOptions[0].badge }) })), selectedOptions.length > 1 && (_jsx(Typography, { size: TypographySize.TextS, weight: TypographyWeight.Regular, color: "var(--color-text-text-title)", textStyle: { whiteSpace: 'nowrap', flexShrink: 0 }, children: `+${selectedOptions.length - 1} more` }))] })) : (_jsxs(_Fragment, { children: [showLeadingIcon && (_jsx(Box, { display: "flex", alignItems: "center", flexShrink: 0, children: getIconComponent(leadingIcon, {
164
+ width: '24px',
165
+ height: '24px',
166
+ color: 'var(--color-foreground-fg-secondary)',
167
+ 'aria-hidden': 'true',
168
+ }) })), _jsx(Typography, { size: TypographySize.TextS, weight: TypographyWeight.Regular, textStyle: {
169
+ flexGrow: 1,
170
+ minWidth: 0,
171
+ textAlign: 'left',
172
+ overflow: 'hidden',
173
+ textOverflow: 'ellipsis',
174
+ whiteSpace: 'nowrap',
175
+ maxWidth: '100%',
176
+ }, color: hasSelection
177
+ ? 'var(--color-text-text-title)'
178
+ : 'var(--color-text-text-placeholder)', children: hasSelection ? displayValue : placeholder })] })) }), _jsxs(Box, { display: "flex", alignItems: "center", gap: "var(--spacing-tokens-size-in-px-spacing-spacing-sm)", flexShrink: 0, children: [!disabled &&
134
179
  showClear &&
135
- value &&
180
+ hasSelection &&
136
181
  getIconComponent('X', {
137
182
  width: '24px',
138
183
  height: '24px',
@@ -153,28 +198,31 @@ export const Dropdown = ({ disabled = false, placeholder = 'Select an option', l
153
198
  height: '24px',
154
199
  color: 'var(--color-foreground-fg-secondary)',
155
200
  'aria-hidden': 'true',
156
- })] })] }), _jsx(Popper, { open: open, anchorEl: anchorRef.current, placement: "bottom-start", style: { width: anchorRef.current?.offsetWidth, zIndex: 1300 }, children: _jsx(Box, { ref: optionsContainerRef, bgcolor: 'var(--color-background-bg-component)', borderRadius: '4px', width: '100%', maxHeight: '200px', overflow: 'auto', boxShadow: '0px 4px 8px rgba(0, 0, 0, 0.1)', mt: '4px', role: "listbox", sx: {
201
+ })] })] }), _jsx(Popper, { open: open, anchorEl: anchorRef.current, placement: "bottom-start", style: { width: anchorRef.current?.offsetWidth, zIndex: 1300 }, children: _jsxs(Box, { ref: optionsContainerRef, onScroll: handleOptionsScroll, bgcolor: 'var(--color-background-bg-component)', borderRadius: '4px', width: '100%', maxHeight: '200px', overflow: 'auto', boxShadow: '0px 4px 8px rgba(0, 0, 0, 0.1)', mt: '4px', role: "listbox", sx: {
157
202
  border: '1px solid var(--color-border-border-component)',
158
- }, children: options.length === 0 ? (_jsx(Box, { display: 'flex', flexDirection: 'row', paddingX: 'var(--spacing-tokens-size-in-px-spacing-spacing-md)', paddingY: 'var(--spacing-tokens-size-in-px-spacing-spacing-sm)', children: _jsx(Typography, { size: TypographySize.TextS, weight: TypographyWeight.Regular, children: "No options found" }) })) : (options.map((option, index) => (_jsxs(Box, { display: 'flex', flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', gap: 'var(--spacing-tokens-size-in-px-spacing-spacing-md)', height: '40px', padding: '0 var(--spacing-tokens-size-in-px-spacing-spacing-lg)', onClick: () => handleOptionSelect(option), onMouseEnter: () => setHighlightedIndex(index), sx: {
159
- cursor: 'pointer',
160
- backgroundColor: highlightedIndex === index
161
- ? 'var(--color-background-bg-secondary-action-hover)'
162
- : 'transparent',
163
- '&:hover': {
164
- backgroundColor: 'var(--color-background-bg-secondary-action-hover)',
165
- },
166
- width: '100%',
167
- boxSizing: 'border-box',
168
- }, role: "option", "aria-selected": isOptionSelected(option), children: [_jsxs(Box, { display: 'flex', flexDirection: 'row', alignItems: 'center', gap: 'var(--spacing-tokens-size-in-px-spacing-spacing-xs)', width: "calc(100% - 28px)", overflow: "hidden", children: [showCheckboxes && (_jsx(Box, { flexShrink: 0, children: _jsx(Checkbox, { checked: isOptionSelected(option), onChange: () => handleOptionSelect(option), style: { margin: 0, padding: 0 } }) })), _jsx(Typography, { size: TypographySize.TextS, weight: TypographyWeight.Regular, color: "var(--color-text-text-title)", textStyle: {
169
- overflow: 'hidden',
170
- textOverflow: 'ellipsis',
171
- whiteSpace: 'nowrap',
172
- width: '100%',
173
- }, children: option })] }), _jsx(Box, { flexShrink: 0, display: "flex", alignItems: "center", children: showTicks &&
174
- isOptionSelected(option) &&
175
- getIconComponent('Check', {
176
- width: '20px',
177
- height: '20px',
178
- color: 'var(--color-foreground-fg-brand-primary)',
179
- }) })] }, option)))) }) })] }) }));
203
+ }, children: [normalizedOptions.length === 0 && !isLoadingMore ? (_jsx(Box, { display: 'flex', flexDirection: 'row', paddingX: 'var(--spacing-tokens-size-in-px-spacing-spacing-md)', paddingY: 'var(--spacing-tokens-size-in-px-spacing-spacing-sm)', children: _jsx(Typography, { size: TypographySize.TextS, weight: TypographyWeight.Regular, children: "No options found" }) })) : (normalizedOptions.map((option, index) => (_jsxs(Box, { display: 'flex', flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', gap: 'var(--spacing-tokens-size-in-px-spacing-spacing-md)', height: '36px', padding: '0 var(--spacing-tokens-size-in-px-spacing-spacing-md)', onClick: () => handleOptionSelect(option.value), onMouseEnter: () => setHighlightedIndex(index), sx: {
204
+ cursor: 'pointer',
205
+ backgroundColor: highlightedIndex === index
206
+ ? 'var(--color-background-bg-secondary-action-hover)'
207
+ : 'transparent',
208
+ '&:hover': {
209
+ backgroundColor: 'var(--color-background-bg-secondary-action-hover)',
210
+ },
211
+ width: '100%',
212
+ boxSizing: 'border-box',
213
+ }, role: "option", "aria-selected": isOptionSelected(option.value), children: [_jsxs(Box, { display: 'flex', flexDirection: 'row', alignItems: 'center', gap: 'var(--spacing-tokens-size-in-px-spacing-spacing-md)', flexGrow: 1, minWidth: 0, overflow: "hidden", children: [showCheckboxes && (_jsx(Box, { flexShrink: 0, children: _jsx(Checkbox, { checked: isOptionSelected(option.value), onChange: () => handleOptionSelect(option.value), style: { margin: 0, padding: 0 } }) })), option.avatar && (_jsx(Box, { component: "img", src: option.avatar, alt: "", width: "24px", height: "24px", borderRadius: "var(--spacing-tokens-size-in-px-radius-radius-full)", flexShrink: 0, sx: {
214
+ objectFit: 'cover',
215
+ border: '0.5px solid var(--color-border-border-divider)',
216
+ } })), _jsx(Box, { flexGrow: 1, minWidth: 0, overflow: "hidden", children: _jsx(Typography, { size: TypographySize.TextS, weight: TypographyWeight.Regular, color: "var(--color-text-text-title)", textStyle: {
217
+ overflow: 'hidden',
218
+ textOverflow: 'ellipsis',
219
+ whiteSpace: 'nowrap',
220
+ width: '100%',
221
+ }, children: option.label }) }), option.badge && (_jsx(Box, { flexShrink: 0, children: _jsx(Badge, { size: BadgeSize.xs, color: BadgeColor.Default, text: option.badge }) }))] }), _jsx(Box, { flexShrink: 0, display: "flex", alignItems: "center", children: showTicks &&
222
+ isOptionSelected(option.value) &&
223
+ getIconComponent('Check', {
224
+ width: '20px',
225
+ height: '20px',
226
+ color: 'var(--color-foreground-fg-brand-primary)',
227
+ }) })] }, option.value)))), isLoadingMore && (_jsx(Box, { display: 'flex', flexDirection: 'row', justifyContent: 'center', alignItems: 'center', height: '36px', width: '100%', role: "status", "aria-label": "Loading more options", children: _jsx(CircularProgress, { size: 20, sx: { color: 'var(--color-foreground-fg-brand-primary)' } }) }))] }) })] }) }));
180
228
  };
@@ -4,3 +4,7 @@ declare const meta: Meta<typeof Dropdown>;
4
4
  export default meta;
5
5
  type Story = StoryObj<typeof Dropdown>;
6
6
  export declare const Default: Story;
7
+ export declare const ObjectOptions: Story;
8
+ export declare const AvatarAndBadge: Story;
9
+ export declare const MultiSelect: Story;
10
+ export declare const InfiniteLoading: Story;
@@ -1,3 +1,5 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useState } from 'react';
1
3
  import { Dropdown } from './Dropdown';
2
4
  import { DropdownType } from './types';
3
5
  const meta = {
@@ -13,10 +15,10 @@ const meta = {
13
15
  tags: ['autodocs'],
14
16
  argTypes: {
15
17
  options: {
16
- control: 'select',
17
- description: 'The options to display in the dropdown',
18
+ control: 'object',
19
+ description: 'The options to display. Accepts a plain string[] (each string is both value and label) or a DropdownOption[] for id-based selection with optional avatar/badge.',
18
20
  table: {
19
- type: { summary: 'string[]' },
21
+ type: { summary: 'string[] | DropdownOption[]' },
20
22
  },
21
23
  },
22
24
  value: {
@@ -91,10 +93,46 @@ const meta = {
91
93
  type: { summary: 'boolean' },
92
94
  },
93
95
  },
96
+ showCheckboxes: {
97
+ control: 'boolean',
98
+ description: 'Whether to show checkboxes on each option (multi-select)',
99
+ table: {
100
+ type: { summary: 'boolean' },
101
+ },
102
+ },
103
+ showTicks: {
104
+ control: 'boolean',
105
+ description: 'Whether to show a trailing tick on the selected option',
106
+ table: {
107
+ type: { summary: 'boolean' },
108
+ defaultValue: { summary: 'true' },
109
+ },
110
+ },
111
+ onEndReached: {
112
+ description: 'Called when the user scrolls near the bottom of the list. Wire to fetchNextPage from an infinite query. Suppressed while isLoadingMore is true.',
113
+ table: {
114
+ type: { summary: 'function' },
115
+ },
116
+ },
117
+ isLoadingMore: {
118
+ control: 'boolean',
119
+ description: 'Whether more options are being loaded. Shows a spinner row at the bottom and suppresses onEndReached.',
120
+ table: {
121
+ type: { summary: 'boolean' },
122
+ defaultValue: { summary: 'false' },
123
+ },
124
+ },
94
125
  },
95
126
  };
96
127
  export default meta;
128
+ // Single-select stories are interactive so the selection round-trips through
129
+ // the same value/onChange the app uses.
130
+ const InteractiveSingleSelect = (args) => {
131
+ const [value, setValue] = useState(typeof args.value === 'string' ? args.value : '');
132
+ return (_jsx(Dropdown, { ...args, type: DropdownType.SingleSelect, value: value, onChange: (next) => setValue(next) }));
133
+ };
97
134
  export const Default = {
135
+ render: (args) => _jsx(InteractiveSingleSelect, { ...args }),
98
136
  args: {
99
137
  options: [
100
138
  'Option 1',
@@ -120,3 +158,125 @@ export const Default = {
120
158
  required: false,
121
159
  },
122
160
  };
161
+ // Object options with a deliberately duplicate label. Because selection now
162
+ // round-trips through `value` (the id) instead of the label, "Alex Morgan"
163
+ // appears twice but each entry stays independently selectable.
164
+ export const ObjectOptions = {
165
+ render: (args) => _jsx(InteractiveSingleSelect, { ...args }),
166
+ args: {
167
+ options: [
168
+ { value: 'user-1', label: 'Alex Morgan' },
169
+ { value: 'user-2', label: 'Alex Morgan' },
170
+ { value: 'user-3', label: 'Jordan Lee' },
171
+ { value: 'user-4', label: 'Sam Rivera' },
172
+ ],
173
+ value: 'user-2',
174
+ onChange: () => { },
175
+ type: DropdownType.SingleSelect,
176
+ placeholder: 'Select a person',
177
+ label: 'Assignee',
178
+ showLeadingIcon: false,
179
+ showClear: true,
180
+ required: false,
181
+ },
182
+ };
183
+ // Rich options: leading avatar + trailing badge pill, matching the Figma
184
+ // "Avatar leading" variant. The trigger mirrors the selected option's avatar
185
+ // and badge.
186
+ const AVATAR_OPTIONS = [
187
+ {
188
+ value: 'agent-1',
189
+ label: 'Emma Wilson',
190
+ avatar: 'https://i.pravatar.cc/48?img=1',
191
+ badge: 'Screening Interview',
192
+ },
193
+ {
194
+ value: 'agent-2',
195
+ label: 'Liam Chen',
196
+ avatar: 'https://i.pravatar.cc/48?img=2',
197
+ badge: 'Technical Round',
198
+ },
199
+ {
200
+ value: 'agent-3',
201
+ label: 'Olivia Brown',
202
+ avatar: 'https://i.pravatar.cc/48?img=3',
203
+ badge: 'Culture Fit',
204
+ },
205
+ {
206
+ value: 'agent-4',
207
+ label: 'Noah Davis',
208
+ avatar: 'https://i.pravatar.cc/48?img=4',
209
+ badge: 'Final Round',
210
+ },
211
+ ];
212
+ export const AvatarAndBadge = {
213
+ render: (args) => _jsx(InteractiveSingleSelect, { ...args }),
214
+ args: {
215
+ options: AVATAR_OPTIONS,
216
+ value: 'agent-1',
217
+ onChange: () => { },
218
+ type: DropdownType.SingleSelect,
219
+ placeholder: 'Select an agent',
220
+ label: 'Interview agent',
221
+ showLeadingIcon: false,
222
+ showClear: true,
223
+ required: false,
224
+ },
225
+ };
226
+ const InteractiveMultiSelect = (args) => {
227
+ const [value, setValue] = useState(Array.isArray(args.value) ? args.value : []);
228
+ return (_jsx(Dropdown, { ...args, type: DropdownType.MultiSelect, value: value, onChange: (next) => setValue(next) }));
229
+ };
230
+ export const MultiSelect = {
231
+ render: (args) => _jsx(InteractiveMultiSelect, { ...args }),
232
+ args: {
233
+ options: AVATAR_OPTIONS,
234
+ value: ['agent-1', 'agent-2'],
235
+ onChange: () => { },
236
+ type: DropdownType.MultiSelect,
237
+ placeholder: 'Select agents',
238
+ label: 'Interview agents',
239
+ showLeadingIcon: false,
240
+ showCheckboxes: true,
241
+ showClear: true,
242
+ required: false,
243
+ },
244
+ };
245
+ // Simulates an infinite query: each time the list nears the bottom,
246
+ // onEndReached appends another page after a short delay while isLoadingMore
247
+ // shows the spinner row.
248
+ const InfiniteLoadingDropdown = (args) => {
249
+ const PAGE_SIZE = 20;
250
+ const TOTAL = 80;
251
+ const [value, setValue] = useState('');
252
+ const [count, setCount] = useState(PAGE_SIZE);
253
+ const [isLoadingMore, setIsLoadingMore] = useState(false);
254
+ const options = Array.from({ length: count }, (_, i) => ({
255
+ value: `item-${i + 1}`,
256
+ label: `Item ${i + 1}`,
257
+ }));
258
+ const handleEndReached = () => {
259
+ if (isLoadingMore || count >= TOTAL)
260
+ return;
261
+ setIsLoadingMore(true);
262
+ setTimeout(() => {
263
+ setCount((prev) => Math.min(prev + PAGE_SIZE, TOTAL));
264
+ setIsLoadingMore(false);
265
+ }, 800);
266
+ };
267
+ return (_jsx(Dropdown, { ...args, type: DropdownType.SingleSelect, options: options, value: value, onChange: (next) => setValue(next), onEndReached: handleEndReached, isLoadingMore: isLoadingMore }));
268
+ };
269
+ export const InfiniteLoading = {
270
+ render: (args) => _jsx(InfiniteLoadingDropdown, { ...args }),
271
+ args: {
272
+ options: [],
273
+ value: '',
274
+ onChange: () => { },
275
+ type: DropdownType.SingleSelect,
276
+ placeholder: 'Scroll to load more',
277
+ label: 'Paginated options',
278
+ showLeadingIcon: false,
279
+ showClear: true,
280
+ required: false,
281
+ },
282
+ };
@@ -5,4 +5,5 @@ export * from './TextArea';
5
5
  export * from './TextInput';
6
6
  export * from './FileUpload';
7
7
  export * from './Dropdown';
8
+ export * from './DatePicker';
8
9
  export * from './types';
@@ -5,4 +5,5 @@ export * from './TextArea';
5
5
  export * from './TextInput';
6
6
  export * from './FileUpload';
7
7
  export * from './Dropdown';
8
+ export * from './DatePicker';
8
9
  export * from './types';
@@ -239,6 +239,25 @@ export declare enum DropdownType {
239
239
  SingleSelect = "SingleSelect",
240
240
  MultiSelect = "MultiSelect"
241
241
  }
242
+ /**
243
+ * A single dropdown option.
244
+ *
245
+ * `value` is the stable identifier that `value`/`onChange` round-trip through,
246
+ * while `label` is what the user sees. Keeping them separate means duplicate or
247
+ * blank labels are no longer ambiguous. `avatar` and `badge` are optional and,
248
+ * when present, render a leading avatar and a trailing badge pill (matching the
249
+ * "Avatar leading" variant in Figma).
250
+ */
251
+ export type DropdownOption = {
252
+ /** Stable identifier used for selection and returned by onChange */
253
+ value: string;
254
+ /** Human-readable text shown for the option */
255
+ label: string;
256
+ /** Optional leading avatar image URL (rendered as a 24px rounded avatar) */
257
+ avatar?: string;
258
+ /** Optional trailing badge pill text (e.g. "Screening Interview") */
259
+ badge?: string;
260
+ };
242
261
  type SingleSelectDropdownProps = {
243
262
  /** The value of the selected option */
244
263
  value: string;
@@ -259,8 +278,13 @@ type DropdownBaseProps = SingleSelectDropdownProps | MultiSelectDropdownProps;
259
278
  export type DropdownProps = DropdownBaseProps & {
260
279
  /** The label of the dropdown */
261
280
  label?: string | null;
262
- /** The options to display in the dropdown */
263
- options: string[];
281
+ /**
282
+ * The options to display in the dropdown. Accepts either a plain array of
283
+ * strings (each string is used as both value and label) or an array of
284
+ * {@link DropdownOption} objects for id-based selection and avatar/badge
285
+ * support.
286
+ */
287
+ options: string[] | DropdownOption[];
264
288
  /** The placeholder of the dropdown */
265
289
  placeholder?: string;
266
290
  /** Whether the dropdown is disabled */
@@ -277,5 +301,67 @@ export type DropdownProps = DropdownBaseProps & {
277
301
  showCheckboxes?: boolean;
278
302
  /** Whether to show the ticks */
279
303
  showTicks?: boolean;
304
+ /**
305
+ * Called when the user scrolls near the bottom of the options list. Wire this
306
+ * to `fetchNextPage` from an infinite query to lazily load more options. It is
307
+ * not fired while `isLoadingMore` is true.
308
+ */
309
+ onEndReached?: () => void;
310
+ /**
311
+ * Whether more options are currently being loaded. When true, a spinner row is
312
+ * shown at the bottom of the list and `onEndReached` is suppressed.
313
+ */
314
+ isLoadingMore?: boolean;
315
+ };
316
+ /**
317
+ * Date picker selection mode.
318
+ *
319
+ * `Single` selects one date; `Range` selects a start/end pair (the design's
320
+ * "Leading"/"Middle"/"Trailing" cell states).
321
+ */
322
+ export declare enum DatePickerSelectionMode {
323
+ Single = "Single",
324
+ Range = "Range"
325
+ }
326
+ /**
327
+ * A start/end date range. Either bound may be `null` while the user is
328
+ * mid-selection (a start picked but no end yet).
329
+ */
330
+ export type DateRange = {
331
+ start: Date | null;
332
+ end: Date | null;
333
+ };
334
+ type SingleDatePickerProps = {
335
+ /** Selects a single date. */
336
+ selectionMode: DatePickerSelectionMode.Single;
337
+ /** The currently selected date, or null when nothing is selected. */
338
+ value: Date | null;
339
+ /** Called with the newly selected date (or null when cleared). */
340
+ onChange: (value: Date | null) => void;
341
+ };
342
+ type RangeDatePickerProps = {
343
+ /** Selects a start/end date range. */
344
+ selectionMode: DatePickerSelectionMode.Range;
345
+ /** The currently selected range. */
346
+ value: DateRange;
347
+ /** Called with the updated range as the user picks start then end. */
348
+ onChange: (value: DateRange) => void;
349
+ };
350
+ type DatePickerBaseProps = SingleDatePickerProps | RangeDatePickerProps;
351
+ export type DatePickerProps = DatePickerBaseProps & {
352
+ /** The label rendered above the input. */
353
+ label?: string | null;
354
+ /** Placeholder shown in the input when no date is selected. */
355
+ placeholder?: string;
356
+ /** Whether the date picker is disabled. */
357
+ disabled?: boolean;
358
+ /** Whether the field is required (renders the required asterisk on the label). */
359
+ required?: boolean;
360
+ /** Whether to show the clear button when a date is selected. */
361
+ showClear?: boolean;
362
+ /** Earliest selectable date (inclusive). Earlier dates are disabled. */
363
+ minDate?: Date;
364
+ /** Latest selectable date (inclusive). Later dates are disabled. */
365
+ maxDate?: Date;
280
366
  };
281
367
  export {};
@@ -44,3 +44,14 @@ export var DropdownType;
44
44
  DropdownType["SingleSelect"] = "SingleSelect";
45
45
  DropdownType["MultiSelect"] = "MultiSelect";
46
46
  })(DropdownType || (DropdownType = {}));
47
+ /**
48
+ * Date picker selection mode.
49
+ *
50
+ * `Single` selects one date; `Range` selects a start/end pair (the design's
51
+ * "Leading"/"Middle"/"Trailing" cell states).
52
+ */
53
+ export var DatePickerSelectionMode;
54
+ (function (DatePickerSelectionMode) {
55
+ DatePickerSelectionMode["Single"] = "Single";
56
+ DatePickerSelectionMode["Range"] = "Range";
57
+ })(DatePickerSelectionMode || (DatePickerSelectionMode = {}));
@@ -9,5 +9,10 @@ export const PlanCard = ({ title, description, price, priceSuffix = '/month', ba
9
9
  ? 'var(--color-background-bg-card-highlight)'
10
10
  : 'var(--color-background-bg-component)', border: `1px solid ${highlighted
11
11
  ? 'var(--color-border-border-brand)'
12
- : 'var(--color-border-border-subtle)'}`, boxSizing: 'border-box', style: style, "data-testid": testId ?? `${planKey}-plan-card`, children: [_jsxs(Box, { display: 'flex', flexDirection: 'column', gap: 'var(--spacing-tokens-size-in-px-spacing-spacing-md)', children: [_jsxs(Box, { display: 'flex', flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', minHeight: '24px', gap: 'var(--spacing-tokens-size-in-px-spacing-spacing-md)', children: [_jsx(Typography, { size: TypographySize.HeadingM, weight: TypographyWeight.Bold, color: 'var(--color-text-text-title)', children: title }), badgeText && (_jsx(Badge, { text: badgeText, color: badgeColor, size: BadgeSize.xs, fill: true }))] }), description && (_jsx(Typography, { size: TypographySize.TextS, weight: TypographyWeight.Regular, color: 'var(--color-text-text-paragraph)', textStyle: { textAlign: 'left' }, children: description })), price && (_jsxs(Box, { display: 'flex', flexDirection: 'row', alignItems: 'baseline', gap: '2px', children: [_jsx(Typography, { size: TypographySize.HeadingL, weight: TypographyWeight.Bold, color: 'var(--color-text-text-title)', children: price }), priceSuffix && (_jsx(Typography, { size: TypographySize.TextXS, weight: TypographyWeight.Regular, color: 'var(--color-text-text-paragraph)', children: priceSuffix }))] }))] }), buttonText && (_jsx(MainButton, { text: buttonText, hierarchy: buttonHierarchy, size: ButtonSize.md, disabled: buttonDisabled, loading: buttonLoading, onClick: onButtonClick, style: { width: '100%' }, testId: `${planKey}-plan-button` })), features.length > 0 && (_jsxs(Box, { display: 'flex', flexDirection: 'column', gap: 'var(--spacing-tokens-size-in-px-spacing-spacing-md)', alignItems: 'flex-start', children: [_jsx(Typography, { size: TypographySize.TextS, weight: TypographyWeight.Medium, color: 'var(--color-text-text-paragraph)', children: featuresTitle }), features.map((feature, index) => (_jsxs(Box, { display: 'flex', flexDirection: 'row', alignItems: 'flex-start', gap: 'var(--spacing-tokens-size-in-px-spacing-spacing-md)', width: '100%', children: [_jsx(CheckIcon, { color: CheckIconColor.Violet, size: CheckIconSize.xs, type: CheckIconType.Duotone }), _jsx(Typography, { size: TypographySize.TextM, weight: TypographyWeight.Regular, color: 'var(--color-text-text-title)', textStyle: { textAlign: 'left' }, children: feature })] }, `${index}-${feature}`)))] }))] }));
12
+ : 'var(--color-border-border-subtle)'}`, boxSizing: 'border-box', style: style, "data-testid": testId ?? `${planKey}-plan-card`, children: [_jsxs(Box, { display: 'flex', flexDirection: 'column', gap: 'var(--spacing-tokens-size-in-px-spacing-spacing-md)', children: [_jsxs(Box, { display: 'flex', flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', minHeight: '24px', gap: 'var(--spacing-tokens-size-in-px-spacing-spacing-md)', children: [_jsx(Typography, { size: TypographySize.HeadingM, weight: TypographyWeight.Bold, color: 'var(--color-text-text-title)', children: title }), badgeText && (_jsx(Badge, { text: badgeText, color: badgeColor, size: BadgeSize.xs, fill: true }))] }), description && (_jsx(Typography, { size: TypographySize.TextS, weight: TypographyWeight.Regular, color: 'var(--color-text-text-paragraph)', textStyle: { textAlign: 'left' }, children: description })), price && (_jsxs(Box, { display: 'flex', flexDirection: 'row', alignItems: 'baseline', gap: '2px', children: [_jsx(Typography, { size: TypographySize.HeadingL, weight: TypographyWeight.Bold, color: 'var(--color-text-text-title)', children: price }), priceSuffix && (_jsx(Typography, { size: TypographySize.TextXS, weight: TypographyWeight.Regular, color: 'var(--color-text-text-paragraph)', children: priceSuffix }))] }))] }), buttonText && (_jsx(MainButton, { text: buttonText, hierarchy: buttonHierarchy, size: ButtonSize.md, disabled: buttonDisabled, loading: buttonLoading, onClick: onButtonClick, style: { width: '100%' }, testId: `${planKey}-plan-button` })), features.length > 0 && (_jsxs(Box, { display: 'flex', flexDirection: 'column', gap: 'var(--spacing-tokens-size-in-px-spacing-spacing-md)', alignItems: 'flex-start', children: [_jsx(Typography, { size: TypographySize.TextS, weight: TypographyWeight.Medium, color: 'var(--color-text-text-paragraph)', children: featuresTitle }), features.map((feature, index) => {
13
+ const isHeading = feature?.startsWith('-');
14
+ return (_jsxs(Box, { display: 'flex', flexDirection: 'row', alignItems: 'flex-start', gap: 'var(--spacing-tokens-size-in-px-spacing-spacing-md)', width: '100%', children: [!isHeading && (_jsx(CheckIcon, { color: CheckIconColor.Violet, size: CheckIconSize.xs, type: CheckIconType.Duotone })), _jsx(Typography, { size: TypographySize.TextM, weight: isHeading
15
+ ? TypographyWeight.Medium
16
+ : TypographyWeight.Regular, color: 'var(--color-text-text-title)', textStyle: { textAlign: 'left' }, children: isHeading ? feature?.slice(1)?.trim() : feature })] }, `${index}-${feature}`));
17
+ })] }))] }));
13
18
  };
@@ -7,3 +7,4 @@ export declare const Pro: Story;
7
7
  export declare const Free: Story;
8
8
  export declare const Personal: Story;
9
9
  export declare const Custom: Story;
10
+ export declare const WithSubheadings: Story;
@@ -123,3 +123,29 @@ export const Custom = {
123
123
  },
124
124
  },
125
125
  };
126
+ export const WithSubheadings = {
127
+ args: {
128
+ title: 'Starter',
129
+ description: 'Tailored for personal projects and independent creators.',
130
+ price: '€100',
131
+ priceSuffix: '/month',
132
+ buttonText: 'Upgrade',
133
+ features: [
134
+ '-E2E Agent API',
135
+ 'Included mins: 90',
136
+ 'Bundle €/min: €0.54',
137
+ 'Overage €/min: €0.33',
138
+ '-A2V API',
139
+ 'Included mins: 180',
140
+ 'Bundle €/min: €0.27',
141
+ 'Overage €/min: €0.18',
142
+ ],
143
+ },
144
+ parameters: {
145
+ docs: {
146
+ description: {
147
+ story: 'Prefix a feature with "-" to render it as a subheading (bold, no check icon) that groups the items below it.',
148
+ },
149
+ },
150
+ },
151
+ };
@@ -21,7 +21,7 @@ export interface PlanCardProps {
21
21
  highlighted?: boolean;
22
22
  /** Heading above the feature list */
23
23
  featuresTitle?: string;
24
- /** Feature list */
24
+ /** Feature list. Prefix an item with "-" to render it as a subheading (no check icon). */
25
25
  features?: string[];
26
26
  /** Action button label (defaults to "Upgrade"); hidden when omitted */
27
27
  buttonText?: string;
@@ -4,7 +4,7 @@ import type { Meta, StoryObj } from '@storybook/react';
4
4
  * they compose on a real settings page, with a placeholder content pane. Both
5
5
  * navs are interactive.
6
6
  */
7
- declare const SettingsPageExample: () => import("react/jsx-runtime").JSX.Element;
7
+ declare const SettingsPageExample: () => import("react").JSX.Element;
8
8
  declare const meta: Meta<typeof SettingsPageExample>;
9
9
  export default meta;
10
10
  type Story = StoryObj<typeof SettingsPageExample>;
@@ -3,4 +3,4 @@ import type { IconProps } from '@phosphor-icons/react';
3
3
  export type IconName = keyof typeof PhosphorIcons;
4
4
  export declare const getIconComponent: (iconName: IconName, props: IconProps & {
5
5
  "data-testid"?: string;
6
- }) => import("react/jsx-runtime").JSX.Element | null;
6
+ }) => import("react").JSX.Element | null;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@beydesign/storybook",
3
3
  "private": false,
4
- "version": "0.2.25",
4
+ "version": "0.2.27",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
7
7
  "module": "dist/index.js",
@@ -44,14 +44,14 @@
44
44
  "devDependencies": {
45
45
  "@chromatic-com/storybook": "^3.2.7",
46
46
  "@storybook/addon-designs": "^8.2.1",
47
- "@storybook/addon-essentials": "^8.6.14",
48
- "@storybook/addon-interactions": "^8.6.14",
49
- "@storybook/addon-onboarding": "^8.6.17",
50
- "@storybook/addon-themes": "^8.6.17",
51
- "@storybook/blocks": "^8.6.14",
52
- "@storybook/react": "^8.6.17",
53
- "@storybook/react-vite": "^8.6.17",
54
- "@storybook/test": "^8.6.15",
47
+ "@storybook/addon-essentials": "^8.6.18",
48
+ "@storybook/addon-interactions": "^8.6.18",
49
+ "@storybook/addon-onboarding": "^8.6.18",
50
+ "@storybook/addon-themes": "^8.6.18",
51
+ "@storybook/blocks": "^8.6.18",
52
+ "@storybook/react": "^8.6.18",
53
+ "@storybook/react-vite": "^8.6.18",
54
+ "@storybook/test": "^8.6.18",
55
55
  "@types/react": "^18.3.28",
56
56
  "@types/react-dom": "^18.3.7",
57
57
  "@typescript-eslint/eslint-plugin": "^7.18.0",
@@ -70,9 +70,13 @@
70
70
  "prettier": "^3.8.1",
71
71
  "react": "^18.3.1",
72
72
  "react-dom": "^18.3.1",
73
- "storybook": "^8.6.17",
73
+ "storybook": "^8.6.18",
74
74
  "typescript-eslint": "^7.18.0",
75
- "vite": "^5.4.21"
75
+ "vite": "^6.4.3"
76
+ },
77
+ "overrides": {
78
+ "esbuild": "^0.25.0",
79
+ "uuid": "^11.1.1"
76
80
  },
77
81
  "eslintConfig": {
78
82
  "extends": [