eidotter 0.37.3 → 0.38.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 CHANGED
@@ -186,7 +186,7 @@ for client apps and `'use client'` files.
186
186
 
187
187
  | Component | Purpose |
188
188
  |---|---|
189
- | `TimelineContainer` | Multi-zoom timeline (year / month / day / hour views) |
189
+ | `TimelineContainer` | Multi-zoom timeline (year / month / day / hour views); text / image / gallery / article entry kinds |
190
190
  | `TimelineNode` | Axis markers with shape variants and glow |
191
191
  | `TextScramble` | DOS text decode animation |
192
192
  | `RetroEffects` | CRT scanlines, noise, phosphor glow |
@@ -1,31 +1,83 @@
1
1
  import React from 'react';
2
2
  export interface SelectOption {
3
+ /** Stable value submitted / reported on change. */
3
4
  value: string;
5
+ /** Visible label. */
4
6
  label: string;
7
+ /** Disable this single option. */
8
+ disabled?: boolean;
5
9
  }
6
- export interface SelectProps {
7
- /** Currently selected value (controlled). */
8
- value: string;
10
+ type SelectSize = 'sm' | 'md' | 'lg' | 'small' | 'medium' | 'large';
11
+ interface SelectBaseProps {
9
12
  /** Options to render. */
10
13
  options: SelectOption[];
11
- /** Fired with the new value on change. */
12
- onChange: (value: string) => void;
13
- /** Optional id for label association. */
14
- id?: string;
15
- /** Accessible label when there is no visible `<label>`. */
14
+ /** Visible field label (rendered above the control). */
15
+ label?: string;
16
+ /** Accessible label when there is no visible `label`. */
16
17
  'aria-label'?: string;
17
- /** Disable the control. */
18
+ /** Text shown when nothing is selected. */
19
+ placeholder?: string;
20
+ /** Helper text below the control. */
21
+ description?: string;
22
+ /** Error message — shown (and marks the field invalid) when `variant="error"`. */
23
+ errorMessage?: string;
24
+ /** Visual / validation variant. */
25
+ variant?: 'default' | 'error';
26
+ /** Control size. small/medium/large are @deprecated aliases for sm/md/lg. */
27
+ size?: SelectSize;
28
+ /** Disable the whole control. */
18
29
  disabled?: boolean;
19
- /** Additional CSS class name. */
30
+ /** Mark the field required (React Aria). */
31
+ isRequired?: boolean;
32
+ /** Enable type-to-filter (ComboBox for single-select; filter input for multi-select). */
33
+ searchable?: boolean;
34
+ /** Placeholder for the search/filter input (searchable variants). */
35
+ searchPlaceholder?: string;
36
+ /** Message shown when filtering matches nothing. */
37
+ emptyMessage?: React.ReactNode;
38
+ /** Form field name (a hidden native input is emitted for form submission). */
39
+ name?: string;
40
+ /** Id applied to the focusable control (for external `<label htmlFor>`). */
41
+ id?: string;
42
+ /** Additional CSS class merged onto the root. */
20
43
  className?: string;
21
44
  }
45
+ interface SingleSelectProps extends SelectBaseProps {
46
+ /** Single-selection mode (default). */
47
+ multiple?: false;
48
+ /** Selected value (controlled). Empty string means nothing selected. */
49
+ value?: string;
50
+ /** Initial value (uncontrolled). */
51
+ defaultValue?: string;
52
+ /** Fired with the new value on change. */
53
+ onChange?: (value: string) => void;
54
+ }
55
+ interface MultiSelectProps extends SelectBaseProps {
56
+ /** Multi-selection mode — `value`/`onChange` operate on string arrays. */
57
+ multiple: true;
58
+ /** Selected values (controlled). */
59
+ value?: string[];
60
+ /** Initial values (uncontrolled). */
61
+ defaultValue?: string[];
62
+ /** Fired with the new array of values on change. */
63
+ onChange?: (value: string[]) => void;
64
+ }
65
+ export type SelectProps = SingleSelectProps | MultiSelectProps;
22
66
  /**
23
- * DOS-styled select — MVP wrapper around a native `<select>` (DMNC-860).
24
- * Ported from Steuerdash.
67
+ * DOS-styled Select / Dropdown built on React Aria primitives (DMNC-593).
68
+ *
69
+ * Three modes via props:
70
+ * - **default** — single-select `Select` (button + popover + listbox)
71
+ * - **`searchable`** — single-select `ComboBox` with type-to-filter
72
+ * - **`multiple`** — multi-select listbox; `value`/`onChange` use string arrays
73
+ * (combine with `searchable` for a filterable multi-select)
25
74
  *
26
- * Font-size is set via inline style, not a `text-dos-*` class: Tailwind v4's
27
- * Preflight form reset (`select { font-size: 100% }`) outranks utility classes
28
- * on a native `<select>`, so the inline style is the reliable escape hatch
29
- * (token-driven). Upstream component fix tracked at eidotter#349.
75
+ * React Aria handles keyboard nav, focus management, and ARIA; phosphor glow on
76
+ * focus/open lives in `Select.css`. The single-value API (`value` / `options` /
77
+ * `onChange`) is backward-compatible with the previous native-`<select>` MVP.
30
78
  */
31
- export declare const Select: React.FC<SelectProps>;
79
+ export declare const Select: {
80
+ (props: SelectProps): React.ReactElement;
81
+ displayName: string;
82
+ };
83
+ export {};
@@ -1,14 +1,101 @@
1
1
  'use client';
2
- import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { useId, useMemo, useRef, useState } from 'react';
4
+ import { Select as AriaSelect, SelectValue as AriaSelectValue, ComboBox as AriaComboBox, ListBox as AriaListBox, ListBoxItem as AriaListBoxItem, Popover as AriaPopover, Button as AriaButton, Input as AriaInput, Label as AriaLabel, Text as AriaText, } from 'react-aria-components';
3
5
  import { cn } from '../../../utils/cn.js';
6
+ // ---------------------------------------------------------------------------
7
+ // Shared style fragments — layout in Tailwind, phosphor in Select.css
8
+ // ---------------------------------------------------------------------------
9
+ const sizeTrigger = {
10
+ sm: 'text-dos-text-sm px-2.5 py-1 min-h-7 gap-2',
11
+ md: 'text-dos-text-md px-3 py-2 min-h-8 gap-2',
12
+ lg: 'text-dos-text-lg px-4 py-2.5 min-h-10 gap-2',
13
+ };
14
+ const normalizeSize = (size = 'md') => ({ small: 'sm', medium: 'md', large: 'lg' }[size] ?? size);
15
+ const labelClass = 'eidotter-select__label text-dos-text-brand font-dos text-dos-text-xs uppercase tracking-wider';
16
+ const descClass = 'eidotter-select__description text-dos-text-muted font-dos text-dos-text-xs';
17
+ const errClass = 'eidotter-select__error text-dos-text-error font-dos text-dos-text-xs';
18
+ const triggerBase = 'eidotter-select__trigger flex items-center w-full bg-transparent text-dos-text-brand font-dos ' +
19
+ 'border-2 border-dos-border-default outline-none box-border';
20
+ const optionClass = 'eidotter-select__option font-dos';
21
+ const DEFAULT_EMPTY = 'NO MATCHES';
22
+ /** Map the empty `''` sentinel / nullish to React Aria's `null` (placeholder). */
23
+ const toKey = (value) => (value == null || value === '' ? null : value);
24
+ // ---------------------------------------------------------------------------
25
+ // Single-select — React Aria Select (button + popover + listbox)
26
+ // ---------------------------------------------------------------------------
27
+ const SingleSelect = ({ options, label, placeholder, description, errorMessage, variant = 'default', size, value, defaultValue, onChange, disabled, isRequired, name, id, className, 'aria-label': ariaLabel, }) => {
28
+ const isInvalid = variant === 'error';
29
+ const isControlled = value !== undefined;
30
+ const sz = normalizeSize(size);
31
+ return (_jsxs(AriaSelect, { "aria-label": !label ? ariaLabel : undefined, selectedKey: isControlled ? toKey(value) : undefined, defaultSelectedKey: !isControlled ? defaultValue : undefined, onSelectionChange: (key) => onChange?.(key == null ? '' : String(key)), isDisabled: disabled, isInvalid: isInvalid, isRequired: isRequired, name: name, placeholder: placeholder, className: cn('eidotter-select flex flex-col gap-1', className), children: [label && _jsx(AriaLabel, { className: labelClass, children: label }), _jsxs(AriaButton, { id: id, className: cn(triggerBase, sizeTrigger[sz]), children: [_jsx(AriaSelectValue, { className: "eidotter-select__value flex-1 text-left truncate" }), _jsx("span", { "aria-hidden": "true", className: "eidotter-select__arrow", children: "\u25BC" })] }), description && !isInvalid && (_jsx(AriaText, { slot: "description", className: descClass, children: description })), errorMessage && isInvalid && (_jsx(AriaText, { slot: "errorMessage", className: errClass, children: errorMessage })), _jsx(AriaPopover, { className: "eidotter-select__popover", children: _jsx(AriaListBox, { className: "eidotter-select__listbox", items: options, children: (item) => (_jsx(AriaListBoxItem, { id: item.value, textValue: item.label, isDisabled: item.disabled, className: optionClass, children: item.label })) }) })] }));
32
+ };
33
+ // ---------------------------------------------------------------------------
34
+ // Searchable single-select — React Aria ComboBox (input + popover + listbox)
35
+ // ---------------------------------------------------------------------------
36
+ const SearchableSelect = ({ options, label, placeholder, description, errorMessage, variant = 'default', size, value, defaultValue, onChange, disabled, isRequired, name, id, className, emptyMessage = DEFAULT_EMPTY, 'aria-label': ariaLabel, }) => {
37
+ const isInvalid = variant === 'error';
38
+ const isControlled = value !== undefined;
39
+ const sz = normalizeSize(size);
40
+ return (_jsxs(AriaComboBox, { "aria-label": !label ? ariaLabel : undefined, defaultItems: options, selectedKey: isControlled ? toKey(value) : undefined, defaultSelectedKey: !isControlled ? defaultValue : undefined, onSelectionChange: (key) => onChange?.(key == null ? '' : String(key)), isDisabled: disabled, isInvalid: isInvalid, isRequired: isRequired, name: name, menuTrigger: "focus", allowsEmptyCollection: true, className: cn('eidotter-select eidotter-select--searchable flex flex-col gap-1', className), children: [label && _jsx(AriaLabel, { className: labelClass, children: label }), _jsxs("div", { className: cn(triggerBase, sizeTrigger[sz]), children: [_jsx(AriaInput, { id: id, placeholder: placeholder, className: "eidotter-select__input flex-1 min-w-0 bg-transparent text-inherit outline-none" }), _jsx(AriaButton, { className: "eidotter-select__arrow-btn", "aria-label": "Show options", children: _jsx("span", { "aria-hidden": "true", className: "eidotter-select__arrow", children: "\u25BC" }) })] }), description && !isInvalid && (_jsx(AriaText, { slot: "description", className: descClass, children: description })), errorMessage && isInvalid && (_jsx(AriaText, { slot: "errorMessage", className: errClass, children: errorMessage })), _jsx(AriaPopover, { className: "eidotter-select__popover", children: _jsx(AriaListBox, { className: "eidotter-select__listbox", renderEmptyState: () => _jsx("div", { className: "eidotter-select__empty", children: emptyMessage }), children: (item) => (_jsx(AriaListBoxItem, { id: item.value, textValue: item.label, isDisabled: item.disabled, className: optionClass, children: item.label })) }) })] }));
41
+ };
42
+ // ---------------------------------------------------------------------------
43
+ // Multi-select — Button trigger + standalone Popover + multi ListBox
44
+ // (React Aria has no high-level multi-select; this composes the primitives.)
45
+ // ---------------------------------------------------------------------------
46
+ const MultiSelect = ({ options, label, placeholder = 'Select…', description, errorMessage, variant = 'default', size, value, defaultValue, onChange, disabled, name, id, className, searchable, searchPlaceholder = 'FILTER…', emptyMessage = DEFAULT_EMPTY, 'aria-label': ariaLabel, }) => {
47
+ const isInvalid = variant === 'error';
48
+ const isControlled = value !== undefined;
49
+ const sz = normalizeSize(size);
50
+ const triggerRef = useRef(null);
51
+ const [open, setOpen] = useState(false);
52
+ const [query, setQuery] = useState('');
53
+ const [internal, setInternal] = useState(defaultValue ?? []);
54
+ const selected = isControlled ? value ?? [] : internal;
55
+ const baseId = useId();
56
+ const labelId = `${baseId}-label`;
57
+ const selectedSet = useMemo(() => new Set(isControlled ? value ?? [] : internal), [isControlled, value, internal]);
58
+ const labelMap = useMemo(() => new Map(options.map((o) => [o.value, o.label])), [options]);
59
+ const filtered = useMemo(() => {
60
+ if (!searchable || !query.trim())
61
+ return options;
62
+ const q = query.trim().toLowerCase();
63
+ return options.filter((o) => o.label.toLowerCase().includes(q));
64
+ }, [options, searchable, query]);
65
+ const handleChange = (selection) => {
66
+ const next = selection === 'all' ? options.map((o) => o.value) : Array.from(selection, String);
67
+ if (!isControlled)
68
+ setInternal(next);
69
+ onChange?.(next);
70
+ };
71
+ // Reset the filter each time the popover opens so stale queries don't hide
72
+ // options on the next open.
73
+ const handleOpenChange = (next) => {
74
+ setOpen(next);
75
+ if (next)
76
+ setQuery('');
77
+ };
78
+ const triggerText = selected.length === 0 ? placeholder : selected.map((v) => labelMap.get(v) ?? v).join(', ');
79
+ return (_jsxs("div", { className: cn('eidotter-select eidotter-select--multi flex flex-col gap-1', className), children: [label && (_jsx("span", { id: labelId, className: labelClass, children: label })), _jsxs(AriaButton, { ref: triggerRef, id: id, isDisabled: disabled, "aria-label": !label ? ariaLabel : undefined, "aria-labelledby": label ? labelId : undefined, "aria-haspopup": "listbox", "aria-expanded": open, onPress: () => handleOpenChange(!open), className: cn(triggerBase, sizeTrigger[sz], isInvalid && 'eidotter-select__trigger--error'), children: [_jsx("span", { className: cn('eidotter-select__value flex-1 text-left truncate', selected.length === 0 && 'eidotter-select__value--placeholder text-dos-text-muted'), children: triggerText }), _jsx("span", { "aria-hidden": "true", className: "eidotter-select__arrow", children: "\u25BC" })] }), description && !isInvalid && _jsx("span", { className: descClass, children: description }), errorMessage && isInvalid && _jsx("span", { className: errClass, children: errorMessage }), name && selected.map((v) => _jsx("input", { type: "hidden", name: name, value: v }, v)), _jsxs(AriaPopover, { triggerRef: triggerRef, isOpen: open, onOpenChange: handleOpenChange, className: "eidotter-select__popover", children: [searchable && (_jsx("input", { autoFocus: true, type: "text", className: "eidotter-select__search font-dos", placeholder: searchPlaceholder, value: query, onChange: (e) => setQuery(e.target.value), "aria-label": "Filter options" })), _jsx(AriaListBox, { "aria-label": ariaLabel || label || 'Options', selectionMode: "multiple", selectedKeys: selectedSet, onSelectionChange: handleChange, items: filtered, autoFocus: searchable ? undefined : 'first', renderEmptyState: () => _jsx("div", { className: "eidotter-select__empty", children: emptyMessage }), className: "eidotter-select__listbox", children: (item) => (_jsx(AriaListBoxItem, { id: item.value, textValue: item.label, isDisabled: item.disabled, className: cn(optionClass, 'eidotter-select__option--multi'), children: ({ isSelected }) => (_jsxs(_Fragment, { children: [_jsx("span", { "aria-hidden": "true", className: "eidotter-select__check", children: isSelected ? '[x]' : '[ ]' }), _jsx("span", { className: "flex-1", children: item.label })] })) })) })] })] }));
80
+ };
4
81
  /**
5
- * DOS-styled select — MVP wrapper around a native `<select>` (DMNC-860).
6
- * Ported from Steuerdash.
82
+ * DOS-styled Select / Dropdown built on React Aria primitives (DMNC-593).
7
83
  *
8
- * Font-size is set via inline style, not a `text-dos-*` class: Tailwind v4's
9
- * Preflight form reset (`select { font-size: 100% }`) outranks utility classes
10
- * on a native `<select>`, so the inline style is the reliable escape hatch
11
- * (token-driven). Upstream component fix tracked at eidotter#349.
84
+ * Three modes via props:
85
+ * - **default** — single-select `Select` (button + popover + listbox)
86
+ * - **`searchable`** — single-select `ComboBox` with type-to-filter
87
+ * - **`multiple`** — multi-select listbox; `value`/`onChange` use string arrays
88
+ * (combine with `searchable` for a filterable multi-select)
89
+ *
90
+ * React Aria handles keyboard nav, focus management, and ARIA; phosphor glow on
91
+ * focus/open lives in `Select.css`. The single-value API (`value` / `options` /
92
+ * `onChange`) is backward-compatible with the previous native-`<select>` MVP.
12
93
  */
13
- export const Select = ({ value, options, onChange, id, disabled, className, 'aria-label': ariaLabel, }) => (_jsx("select", { id: id, "aria-label": ariaLabel, value: value, disabled: disabled, onChange: (e) => onChange(e.target.value), style: { fontSize: 'var(--typography-font-size-text-xs)' }, className: cn('eidotter-select', 'min-w-[5rem] cursor-pointer font-dos px-2 py-1', 'bg-dos-bg-secondary text-dos-text-primary border border-dos-border-default', 'hover:border-dos-text-accent focus:border-dos-text-accent focus:outline-none', 'disabled:cursor-not-allowed disabled:opacity-50', className), children: options.map((o) => (_jsx("option", { value: o.value, children: o.label }, o.value))) }));
94
+ export const Select = (props) => {
95
+ if (props.multiple)
96
+ return _jsx(MultiSelect, { ...props });
97
+ if (props.searchable)
98
+ return _jsx(SearchableSelect, { ...props });
99
+ return _jsx(SingleSelect, { ...props });
100
+ };
14
101
  Select.displayName = 'Select';
@@ -12,9 +12,11 @@ export interface TimelineEntryCardProps {
12
12
  * Dispatcher for content-aware timeline entry rendering.
13
13
  *
14
14
  * Branches on `entry.kind`:
15
- * - `text` — existing trigger+panel rendering with `entry.content`.
16
- * - `image` — placeholder for now; filled in by Task 6.
17
- * - `gallery` — placeholder for now; filled in by Tasks 7–8.
15
+ * - `text` — trigger + expandable panel with `entry.content`.
16
+ * - `image` — single-image thumbnail → lightbox.
17
+ * - `gallery` — thumbnail grid → focused → lightbox.
18
+ * - `article` — devlog card: collapsed thumb-strip + summary; expanded
19
+ * gallery + body + read-more link.
18
20
  *
19
21
  * The card chrome (border, hover slide+glow, selected/expanded states)
20
22
  * lives on the outer `.eidotter-timeline-card` element and applies to all
@@ -5,13 +5,16 @@ import { Button as AriaButton } from 'react-aria-components';
5
5
  import { cn } from '../../../utils/cn.js';
6
6
  import { TimelineEntryCardImage } from './variants/TimelineEntryCardImage.js';
7
7
  import { TimelineEntryCardGallery } from './variants/TimelineEntryCardGallery.js';
8
+ import { TimelineEntryCardArticle } from './variants/TimelineEntryCardArticle.js';
8
9
  /**
9
10
  * Dispatcher for content-aware timeline entry rendering.
10
11
  *
11
12
  * Branches on `entry.kind`:
12
- * - `text` — existing trigger+panel rendering with `entry.content`.
13
- * - `image` — placeholder for now; filled in by Task 6.
14
- * - `gallery` — placeholder for now; filled in by Tasks 7–8.
13
+ * - `text` — trigger + expandable panel with `entry.content`.
14
+ * - `image` — single-image thumbnail → lightbox.
15
+ * - `gallery` — thumbnail grid → focused → lightbox.
16
+ * - `article` — devlog card: collapsed thumb-strip + summary; expanded
17
+ * gallery + body + read-more link.
15
18
  *
16
19
  * The card chrome (border, hover slide+glow, selected/expanded states)
17
20
  * lives on the outer `.eidotter-timeline-card` element and applies to all
@@ -33,6 +36,8 @@ function renderBranch(entry, isExpanded, onSelect, children) {
33
36
  return _jsx(TimelineEntryCardImage, { entry: entry, isExpanded: isExpanded, onSelect: onSelect });
34
37
  case 'gallery':
35
38
  return _jsx(TimelineEntryCardGallery, { entry: entry, isExpanded: isExpanded, onSelect: onSelect });
39
+ case 'article':
40
+ return (_jsx(TimelineEntryCardArticle, { entry: entry, isExpanded: isExpanded, onSelect: onSelect, children: children }));
36
41
  default:
37
42
  return assertNever(entry);
38
43
  }
@@ -56,6 +56,29 @@ export type TimelineEntryData = (TimelineEntryBase & {
56
56
  }) | (TimelineEntryBase & {
57
57
  kind: 'gallery';
58
58
  images: TimelineImage[];
59
+ }) | (TimelineEntryBase & {
60
+ kind: 'article';
61
+ /** Short plain-text preview shown while collapsed (80-char truncated, like the `text` kind). */
62
+ summary?: string;
63
+ /** Full body rendered in the expandable panel. */
64
+ content?: ReactNode;
65
+ /**
66
+ * Hero images: a decorative thumbnail strip while collapsed; an
67
+ * interactive gallery + Lightbox when expanded.
68
+ */
69
+ images?: TimelineImage[];
70
+ /**
71
+ * "Read more" URL. Rendered as a plain `<a>` inside the expanded panel;
72
+ * it never hijacks expand/collapse. Sanitized via `isSafeHref`.
73
+ *
74
+ * Contrast with {@link TimelineImage.link}: an image `link` *replaces*
75
+ * the image interaction (navigates out, skips the lightbox), whereas an
76
+ * article `href` is *supplementary* — a separate anchor that never
77
+ * interferes with expand/collapse or the gallery.
78
+ */
79
+ href?: string;
80
+ /** Read-more link label. @default 'READ MORE' */
81
+ hrefLabel?: string;
59
82
  });
60
83
  /**
61
84
  * @deprecated Use `TimelineEntryData` instead. Existing entries must add a
@@ -0,0 +1,20 @@
1
+ import React from 'react';
2
+ import type { TimelineImage } from '../types.js';
3
+ export interface GalleryGridProps {
4
+ /** Images to render as a grid. */
5
+ images: TimelineImage[];
6
+ /** When false (parent collapsed) the interaction state resets to the grid. */
7
+ isExpanded: boolean;
8
+ }
9
+ /**
10
+ * Shared iOS-Photos-style gallery: grid → focused (grow-in-place) → lightbox.
11
+ *
12
+ * Owns the full interaction state machine **and** the Lightbox mount, so both
13
+ * the `gallery` entry variant and the `article` variant's expanded body can
14
+ * drop it in with only `{ images, isExpanded }`. Resets to grid on parent
15
+ * collapse via a `useEffect` on `isExpanded`.
16
+ *
17
+ * Class names retain the `eidotter-timeline-card-gallery__*` prefix so existing
18
+ * gallery styling and consumer hooks keep working after the extraction.
19
+ */
20
+ export declare const GalleryGrid: React.FC<GalleryGridProps>;
@@ -0,0 +1,69 @@
1
+ 'use client';
2
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useEffect, useState } from 'react';
4
+ import { Button as AriaButton } from 'react-aria-components';
5
+ import { Lightbox } from '../../../Lightbox/index.js';
6
+ import { cn } from '../../../../utils/cn.js';
7
+ import { isSafeHref } from '../../../../utils/isSafeHref.js';
8
+ /**
9
+ * Shared iOS-Photos-style gallery: grid → focused (grow-in-place) → lightbox.
10
+ *
11
+ * Owns the full interaction state machine **and** the Lightbox mount, so both
12
+ * the `gallery` entry variant and the `article` variant's expanded body can
13
+ * drop it in with only `{ images, isExpanded }`. Resets to grid on parent
14
+ * collapse via a `useEffect` on `isExpanded`.
15
+ *
16
+ * Class names retain the `eidotter-timeline-card-gallery__*` prefix so existing
17
+ * gallery styling and consumer hooks keep working after the extraction.
18
+ */
19
+ export const GalleryGrid = ({ images, isExpanded }) => {
20
+ const [state, setState] = useState({ phase: 'grid' });
21
+ // Reset when the parent collapses, so re-expanding starts at grid.
22
+ useEffect(() => {
23
+ if (!isExpanded)
24
+ setState({ phase: 'grid' });
25
+ }, [isExpanded]);
26
+ // Re-validate state.index when images shrinks — otherwise a stale index can
27
+ // feed an out-of-range value into the Lightbox below.
28
+ useEffect(() => {
29
+ setState((prev) => {
30
+ if (prev.phase === 'grid')
31
+ return prev;
32
+ if (prev.index >= images.length)
33
+ return { phase: 'grid' };
34
+ return prev;
35
+ });
36
+ }, [images.length]);
37
+ if (images.length === 0)
38
+ return null;
39
+ const handleThumbClick = (index, image) => {
40
+ // Only short-circuit when the link will actually render as a navigable
41
+ // anchor — unsafe schemes are stripped by GalleryThumb, in which case the
42
+ // thumb falls back to the focus/lightbox interaction.
43
+ if (image.link && isSafeHref(image.link))
44
+ return;
45
+ setState((prev) => {
46
+ if (prev.phase === 'focused' && prev.index === index) {
47
+ return { phase: 'lightbox', index };
48
+ }
49
+ return { phase: 'focused', index };
50
+ });
51
+ };
52
+ const focusedIndex = state.phase === 'focused' || state.phase === 'lightbox' ? state.index : null;
53
+ return (_jsxs(_Fragment, { children: [_jsx("div", { className: "eidotter-timeline-card-gallery__grid", role: "list", children: images.map((img, i) => (_jsx(GalleryThumb, { image: img, isFocused: focusedIndex === i, onClick: () => handleThumbClick(i, img) }, i))) }), _jsx(Lightbox, { images: images, isOpen: state.phase === 'lightbox', initialIndex: state.phase === 'lightbox' ? state.index : 0, onClose: () => {
54
+ setState((prev) => prev.phase === 'lightbox' ? { phase: 'focused', index: prev.index } : prev);
55
+ }, onIndexChange: (idx) => {
56
+ setState((prev) => prev.phase === 'lightbox' ? { phase: 'lightbox', index: idx } : prev);
57
+ } })] }));
58
+ };
59
+ const GalleryThumb = ({ image, isFocused, onClick }) => {
60
+ const src = image.thumbnail || image.src;
61
+ const linkHref = image.link && isSafeHref(image.link) ? image.link : undefined;
62
+ // Outer listitem wraps either an anchor (link mode) or a button (interactive
63
+ // focus/lightbox mode). The listitem semantics live on the outer wrapper so
64
+ // both modes participate in the parent role="list" consistently.
65
+ if (linkHref) {
66
+ return (_jsx("div", { role: "listitem", className: "eidotter-timeline-card-gallery__cell eidotter-timeline-card-gallery__cell--link", children: _jsx("a", { className: "eidotter-timeline-card-gallery__link", href: linkHref, rel: "noopener noreferrer", children: _jsx("img", { className: "eidotter-timeline-card-gallery__img", src: src, alt: image.alt, loading: "lazy", width: image.width, height: image.height }) }) }));
67
+ }
68
+ return (_jsx("div", { role: "listitem", className: cn('eidotter-timeline-card-gallery__cell', isFocused && 'eidotter-timeline-card-gallery__cell--focused'), children: _jsx(AriaButton, { className: "eidotter-timeline-card-gallery__button", onPress: onClick, "aria-label": image.alt || 'Image', "aria-pressed": isFocused, children: _jsx("img", { className: "eidotter-timeline-card-gallery__img", src: src, alt: image.alt, width: image.width, height: image.height }) }) }));
69
+ };
@@ -0,0 +1,26 @@
1
+ import React from 'react';
2
+ import type { TimelineEntryData } from '../types.js';
3
+ type ArticleEntry = Extract<TimelineEntryData, {
4
+ kind: 'article';
5
+ }>;
6
+ export interface TimelineEntryCardArticleProps {
7
+ entry: ArticleEntry;
8
+ isExpanded: boolean;
9
+ onSelect?: (id: string) => void;
10
+ children?: React.ReactNode;
11
+ }
12
+ /**
13
+ * Article variant of TimelineEntryCard — a devlog-style entry.
14
+ *
15
+ * Collapsed: type/tags header, title, an 80-char `summary` preview, then a
16
+ * decorative thumbnail strip. Everything sits inside the single trigger button
17
+ * (`aria-hidden` thumbs, no nested interactive elements) so a click anywhere
18
+ * toggles expand/collapse.
19
+ *
20
+ * Expanded: uses the shared always-in-DOM `__body`/`__body-inner` + `inert` +
21
+ * 0fr→1fr grid pattern (like the text branch), so the interactive gallery,
22
+ * body content, and sanitized "READ MORE →" link are unfocusable/unclickable
23
+ * while collapsed — correct a11y for free.
24
+ */
25
+ export declare const TimelineEntryCardArticle: React.FC<TimelineEntryCardArticleProps>;
26
+ export {};
@@ -0,0 +1,32 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { Button as AriaButton } from 'react-aria-components';
4
+ import { isSafeHref } from '../../../../utils/isSafeHref.js';
5
+ import { GalleryGrid } from './GalleryGrid.js';
6
+ /** Max thumbnails shown in the collapsed decorative strip before a `+N` cell. */
7
+ const MAX_THUMBS = 4;
8
+ /**
9
+ * Article variant of TimelineEntryCard — a devlog-style entry.
10
+ *
11
+ * Collapsed: type/tags header, title, an 80-char `summary` preview, then a
12
+ * decorative thumbnail strip. Everything sits inside the single trigger button
13
+ * (`aria-hidden` thumbs, no nested interactive elements) so a click anywhere
14
+ * toggles expand/collapse.
15
+ *
16
+ * Expanded: uses the shared always-in-DOM `__body`/`__body-inner` + `inert` +
17
+ * 0fr→1fr grid pattern (like the text branch), so the interactive gallery,
18
+ * body content, and sanitized "READ MORE →" link are unfocusable/unclickable
19
+ * while collapsed — correct a11y for free.
20
+ */
21
+ export const TimelineEntryCardArticle = ({ entry, isExpanded, onSelect, children, }) => {
22
+ const { summary, content, href, hrefLabel } = entry;
23
+ const imageList = entry.images ?? [];
24
+ const hasImages = imageList.length > 0;
25
+ const readMoreHref = href && isSafeHref(href) ? href : undefined;
26
+ // The expanded body is always mounted (inert when collapsed) so the grid can
27
+ // animate. Render it whenever there's a gallery, body content, or read-more.
28
+ const hasBody = hasImages || content != null || readMoreHref !== undefined;
29
+ const thumbs = imageList.slice(0, MAX_THUMBS);
30
+ const overflow = imageList.length - thumbs.length;
31
+ return (_jsxs(_Fragment, { children: [_jsxs(AriaButton, { className: "eidotter-timeline-card__trigger", onPress: () => onSelect?.(entry.id), "aria-expanded": isExpanded, children: [_jsxs("div", { className: "eidotter-timeline-card__header", children: [entry.type && (_jsx("span", { className: "eidotter-timeline-card__type", children: entry.type.toUpperCase() })), entry.tags && entry.tags.length > 0 && (_jsx("span", { className: "eidotter-timeline-card__tags", children: entry.tags.map((t) => `#${t}`).join(' ') }))] }), _jsx("p", { className: "eidotter-timeline-card__title", children: entry.title }), !isExpanded && typeof summary === 'string' && summary.length > 0 && (_jsxs("p", { className: "eidotter-timeline-card__preview", children: [summary.slice(0, 80), summary.length > 80 ? '...' : ''] })), children, !isExpanded && hasImages && (_jsxs("div", { className: "eidotter-timeline-card-article__thumbstrip", "aria-hidden": "true", children: [thumbs.map((img, i) => (_jsx("span", { className: "eidotter-timeline-card-article__thumb", children: _jsx("img", { className: "eidotter-timeline-card-article__thumb-img", src: img.thumbnail || img.src, alt: "", loading: "lazy", width: img.width, height: img.height }) }, i))), overflow > 0 && (_jsxs("span", { className: "eidotter-timeline-card-article__thumb eidotter-timeline-card-article__thumb--more", children: ["+", overflow] }))] }))] }), hasBody && (_jsx("div", { className: "eidotter-timeline-card__body", children: _jsxs("div", { className: "eidotter-timeline-card__body-inner", inert: !isExpanded ? true : undefined, children: [hasImages && _jsx(GalleryGrid, { images: imageList, isExpanded: isExpanded }), content != null && (_jsx("div", { className: "eidotter-timeline-card-article__content", children: content })), readMoreHref && (_jsx("div", { className: "eidotter-timeline-card-article__footer", children: _jsxs("a", { className: "eidotter-timeline-card-article__readmore", href: readMoreHref, rel: "noopener noreferrer", children: [hrefLabel ?? 'READ MORE', " \u2192"] }) }))] }) }))] }));
32
+ };
@@ -9,9 +9,9 @@ export interface TimelineEntryCardGalleryProps {
9
9
  onSelect?: (id: string) => void;
10
10
  }
11
11
  /**
12
- * Gallery variant of TimelineEntryCard. iOS Photos two-stage interaction:
13
- * grid → focused (grow-in-place) → lightbox. Resets to grid on parent
14
- * collapse via useEffect on `isExpanded`.
12
+ * Gallery variant of TimelineEntryCard. Thin wrapper: renders the trigger
13
+ * (title) + the shared {@link GalleryGrid} (grid → focused → lightbox). Empty
14
+ * galleries render a title + placeholder instead of a trigger.
15
15
  */
16
16
  export declare const TimelineEntryCardGallery: React.FC<TimelineEntryCardGalleryProps>;
17
17
  export {};
@@ -1,67 +1,18 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { useEffect, useState } from 'react';
4
3
  import { Button as AriaButton } from 'react-aria-components';
5
- import { Lightbox } from '../../../Lightbox/index.js';
6
- import { cn } from '../../../../utils/cn.js';
7
- import { isSafeHref } from '../../../../utils/isSafeHref.js';
4
+ import { GalleryGrid } from './GalleryGrid.js';
8
5
  /**
9
- * Gallery variant of TimelineEntryCard. iOS Photos two-stage interaction:
10
- * grid → focused (grow-in-place) → lightbox. Resets to grid on parent
11
- * collapse via useEffect on `isExpanded`.
6
+ * Gallery variant of TimelineEntryCard. Thin wrapper: renders the trigger
7
+ * (title) + the shared {@link GalleryGrid} (grid → focused → lightbox). Empty
8
+ * galleries render a title + placeholder instead of a trigger.
12
9
  */
13
10
  export const TimelineEntryCardGallery = ({ entry, isExpanded, onSelect, }) => {
14
- const [state, setState] = useState({ phase: 'grid' });
15
- // Reset when the parent card collapses, so re-expanding starts at grid.
16
- useEffect(() => {
17
- if (!isExpanded)
18
- setState({ phase: 'grid' });
19
- }, [isExpanded]);
20
- // Re-validate state.index when entry.images shrinks — otherwise a stale
21
- // index can feed an out-of-range value into the Lightbox below.
22
- useEffect(() => {
23
- setState((prev) => {
24
- if (prev.phase === 'grid')
25
- return prev;
26
- if (prev.index >= entry.images.length)
27
- return { phase: 'grid' };
28
- return prev;
29
- });
30
- }, [entry.images.length]);
31
11
  if (entry.images.length === 0) {
32
12
  if (process.env.NODE_ENV !== 'production') {
33
13
  console.error(`[eidotter] TimelineEntryCard kind="gallery" entry "${entry.id}" has no images.`);
34
14
  }
35
15
  return (_jsxs(_Fragment, { children: [_jsx("p", { className: "eidotter-timeline-card__title", children: entry.title }), _jsx("p", { className: "eidotter-timeline-card-gallery__empty", children: "No images." })] }));
36
16
  }
37
- const handleThumbClick = (index, image) => {
38
- // Only short-circuit when the link will actually render as a navigable
39
- // anchor — unsafe schemes are stripped by GalleryThumb, in which case
40
- // the thumb falls back to the focus/lightbox interaction.
41
- if (image.link && isSafeHref(image.link))
42
- return;
43
- setState((prev) => {
44
- if (prev.phase === 'focused' && prev.index === index) {
45
- return { phase: 'lightbox', index };
46
- }
47
- return { phase: 'focused', index };
48
- });
49
- };
50
- const focusedIndex = state.phase === 'focused' || state.phase === 'lightbox' ? state.index : null;
51
- return (_jsxs(_Fragment, { children: [_jsx(AriaButton, { className: "eidotter-timeline-card__trigger", onPress: () => onSelect?.(entry.id), "aria-expanded": isExpanded, children: _jsx("p", { className: "eidotter-timeline-card__title", children: entry.title }) }), _jsx("div", { className: "eidotter-timeline-card-gallery__grid", role: "list", children: entry.images.map((img, i) => (_jsx(GalleryThumb, { image: img, isFocused: focusedIndex === i, onClick: () => handleThumbClick(i, img) }, i))) }), _jsx(Lightbox, { images: entry.images, isOpen: state.phase === 'lightbox', initialIndex: state.phase === 'lightbox' ? state.index : 0, onClose: () => {
52
- setState((prev) => prev.phase === 'lightbox' ? { phase: 'focused', index: prev.index } : prev);
53
- }, onIndexChange: (idx) => {
54
- setState((prev) => prev.phase === 'lightbox' ? { phase: 'lightbox', index: idx } : prev);
55
- } })] }));
56
- };
57
- const GalleryThumb = ({ image, isFocused, onClick }) => {
58
- const src = image.thumbnail || image.src;
59
- const linkHref = image.link && isSafeHref(image.link) ? image.link : undefined;
60
- // Outer listitem wraps either an anchor (link mode) or a button (interactive
61
- // focus/lightbox mode). The listitem semantics live on the outer wrapper so
62
- // both modes participate in the parent role="list" consistently.
63
- if (linkHref) {
64
- return (_jsx("div", { role: "listitem", className: "eidotter-timeline-card-gallery__cell eidotter-timeline-card-gallery__cell--link", children: _jsx("a", { className: "eidotter-timeline-card-gallery__link", href: linkHref, rel: "noopener noreferrer", children: _jsx("img", { className: "eidotter-timeline-card-gallery__img", src: src, alt: image.alt, width: image.width, height: image.height }) }) }));
65
- }
66
- return (_jsx("div", { role: "listitem", className: cn('eidotter-timeline-card-gallery__cell', isFocused && 'eidotter-timeline-card-gallery__cell--focused'), children: _jsx(AriaButton, { className: "eidotter-timeline-card-gallery__button", onPress: onClick, "aria-label": image.alt || 'Image', "aria-pressed": isFocused, children: _jsx("img", { className: "eidotter-timeline-card-gallery__img", src: src, alt: image.alt, width: image.width, height: image.height }) }) }));
17
+ return (_jsxs(_Fragment, { children: [_jsx(AriaButton, { className: "eidotter-timeline-card__trigger", onPress: () => onSelect?.(entry.id), "aria-expanded": isExpanded, children: _jsx("p", { className: "eidotter-timeline-card__title", children: entry.title }) }), _jsx(GalleryGrid, { images: entry.images, isExpanded: isExpanded })] }));
67
18
  };
@@ -13,7 +13,7 @@ import { formatTimestamp } from '../timelineUtils.js';
13
13
  export const HourView = React.memo(({ buckets, selectedEntryId, onEntrySelect, renderEntry, }) => {
14
14
  return (_jsx("div", { className: "timeline-view timeline-view--hour", role: "list", children: buckets.map((bucket) => (_jsxs("div", { className: "timeline-view__bucket timeline-view__bucket--hour", role: "listitem", children: [_jsx("div", { className: "timeline-view__node", children: _jsx(TimelineNode, { shape: "circle", size: "medium", variant: "default", label: bucket.label, labelPosition: "right" }) }), _jsx("div", { className: "timeline-view__content", children: bucket.entries.map((entry) => {
15
15
  const isSelected = selectedEntryId === entry.id;
16
- const defaultRender = () => (_jsxs(TimelineEntryCard, { entry: entry, isSelected: isSelected, isExpanded: true, onSelect: onEntrySelect, footer: _jsxs("div", { className: "timeline-view__entry-footer", children: [entry.tags && entry.tags.length > 0 && (_jsx(TagGroup, { gap: "tight", "aria-label": `Tags for ${entry.title}`, children: entry.tags.map((tag) => (_jsx(Tag, { size: "small", variant: "outlined", children: tag }, tag))) })), entry.type && (_jsx(Badge, { variant: "default", size: "small", children: entry.type }))] }), children: [_jsx("time", { className: "timeline-view__timestamp", dateTime: entry.date, children: formatTimestamp(entry.date) }), entry.kind === 'text' && entry.content !== undefined && (_jsx("div", { className: "timeline-view__entry-content", children: entry.content }))] }));
16
+ const defaultRender = () => (_jsxs(TimelineEntryCard, { entry: entry, isSelected: isSelected, isExpanded: true, onSelect: onEntrySelect, footer: _jsxs("div", { className: "timeline-view__entry-footer", children: [entry.tags && entry.tags.length > 0 && (_jsx(TagGroup, { gap: "tight", "aria-label": `Tags for ${entry.title}`, children: entry.tags.map((tag) => (_jsx(Tag, { size: "small", variant: "outlined", children: tag }, tag))) })), entry.type && (_jsx(Badge, { variant: "default", size: "small", children: entry.type }))] }), children: [_jsx("time", { className: "timeline-view__timestamp", dateTime: entry.date, children: formatTimestamp(entry.date) }), entry.kind === 'text' && entry.content !== undefined && (_jsx("div", { className: "timeline-view__entry-content", children: entry.content })), entry.kind === 'article' && entry.summary !== undefined && (_jsx("div", { className: "timeline-view__entry-content", children: entry.summary }))] }));
17
17
  return (_jsx(React.Fragment, { children: renderEntry
18
18
  ? renderEntry(entry, { isExpanded: true, isSelected, defaultRender })
19
19
  : defaultRender() }, entry.id));
@@ -308,6 +308,29 @@ export const componentRegistry = {
308
308
  { version: '0.3.0', type: 'added', description: 'Initial RetroEffects — scanlines, glow, flicker, bloom, intensity, powered' },
309
309
  ],
310
310
  },
311
+ Select: {
312
+ origin: 'steuerdash',
313
+ consumers: ['steuerdash'],
314
+ since: '0.36.0',
315
+ originNote: 'DOS-styled dropdown. MVP was a native <select> ported from Steuerdash; rebuilt on React Aria Select/ComboBox in DMNC-593 with default/searchable/multi modes',
316
+ variants: {
317
+ 'mode:default': { description: 'Single-select — React Aria Select (button + popover + listbox)', since: '0.38.0', usedBy: ['steuerdash'] },
318
+ 'searchable:true': { description: 'Type-to-filter ComboBox single-select', since: '0.38.0' },
319
+ 'multiple:true': { description: 'Multi-select listbox; value/onChange use string arrays', since: '0.38.0' },
320
+ 'variant:error': { description: 'Invalid state — red border + errorMessage slot', since: '0.38.0' },
321
+ 'size:sm': { description: 'Compact trigger (28px min-height)', since: '0.38.0' },
322
+ 'size:md': { description: 'Standard trigger (32px min-height)', since: '0.38.0', usedBy: ['steuerdash'] },
323
+ 'size:lg': { description: 'Tall trigger (40px min-height)', since: '0.38.0' },
324
+ },
325
+ platforms: {
326
+ react: { path: 'src/components/Select', status: 'canonical' },
327
+ swiftui: { status: 'planned' },
328
+ },
329
+ changelog: [
330
+ { version: '0.38.0', type: 'changed', description: 'Rebuild on React Aria (Select/ComboBox/ListBox) with phosphor glow; add searchable + multiple modes, label/description/error chrome, sizes. Backward-compatible value/options/onChange API (DMNC-593)' },
331
+ { version: '0.36.0', type: 'added', description: 'Initial Select — native <select> MVP ported from Steuerdash (DMNC-860)' },
332
+ ],
333
+ },
311
334
  Skeleton: { origin: 'eidotter', consumers: ['dmnctech'], originNote: 'DOS/CGA loading placeholder — shade-character rows (\u2591\u2592\u2593) with CRT hum-bar band rolling top\u2192bottom (DMNC-1018)' },
312
335
  Stat: {
313
336
  origin: 'steuerdash',