@datalayer/primer-addons 1.0.22 → 1.0.23

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.
@@ -1,7 +1,6 @@
1
1
  /*
2
2
  * Copyright (c) 2023-2026 Datalayer, Inc.
3
- *
4
- * Datalayer License
3
+ * Distributed under the terms of the Modified BSD License.
5
4
  */
6
5
  import { Box } from '@primer/react';
7
6
  export { Box };
@@ -1,7 +1,6 @@
1
1
  /*
2
2
  * Copyright (c) 2021-2024 Datalayer, Inc.
3
- *
4
- * Datalayer License
3
+ * Distributed under the terms of the Modified BSD License.
5
4
  */
6
5
  import css from '@styled-system/css';
7
6
  import merge from 'deepmerge';
@@ -1,7 +1,29 @@
1
+ /**
2
+ * `CloseableFlash`: a Primer `Flash` the reader can dismiss.
3
+ *
4
+ * Primer's own variants are `default`, `success`, `warning` and `danger`. Two
5
+ * more are added here, for the levels a platform announce is written at:
6
+ * `info`, which is the neutral blue Primer already draws for `default` and is
7
+ * worth having under the name people say, and `critical`, which is `danger`
8
+ * wearing its emphasis colour — an incident has to look different from a
9
+ * validation error, and a reader who sees everything red stops reading red.
10
+ *
11
+ * @module components/closeable-flash/CloseableFlash
12
+ */
1
13
  import { FC } from "react";
2
14
  import { FlashProps } from "@primer/react";
3
- export type CloseableFlashProps = FlashProps & {
15
+ /** What a flash may be, Primer's own plus the announce levels. */
16
+ export type CloseableFlashVariant = NonNullable<FlashProps['variant']> | 'info' | 'critical';
17
+ export type CloseableFlashProps = Omit<FlashProps, 'variant'> & {
18
+ variant?: CloseableFlashVariant;
4
19
  leadingVisual?: React.ComponentType;
5
20
  onClose?: () => void;
21
+ /**
22
+ * Whether the reader may close it. `false` draws the same flash without the
23
+ * close button, which is what a preview of one wants: an editor showing an
24
+ * announce as it will look should not offer to dismiss something that is
25
+ * not being shown to anybody yet.
26
+ */
27
+ dismissible?: boolean;
6
28
  };
7
29
  export declare const CloseableFlash: FC<CloseableFlashProps>;
@@ -1,7 +1,40 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /*
3
+ * Copyright (c) 2023-2026 Datalayer, Inc.
4
+ * Distributed under the terms of the Modified BSD License.
5
+ */
6
+ /**
7
+ * `CloseableFlash`: a Primer `Flash` the reader can dismiss.
8
+ *
9
+ * Primer's own variants are `default`, `success`, `warning` and `danger`. Two
10
+ * more are added here, for the levels a platform announce is written at:
11
+ * `info`, which is the neutral blue Primer already draws for `default` and is
12
+ * worth having under the name people say, and `critical`, which is `danger`
13
+ * wearing its emphasis colour — an incident has to look different from a
14
+ * validation error, and a reader who sees everything red stops reading red.
15
+ *
16
+ * @module components/closeable-flash/CloseableFlash
17
+ */
2
18
  import { useState } from "react";
3
19
  import { Box, Flash, CircleOcticon, Text } from "@primer/react";
4
20
  import { XIcon } from "@primer/octicons-react";
21
+ /**
22
+ * The Primer variant that draws a given level.
23
+ *
24
+ * `info` is Primer's `default` — already the accent blue — and `critical` is
25
+ * `danger`; what sets the two dangers apart is the emphasis below, not the
26
+ * variant, because Primer has no louder red to ask for.
27
+ */
28
+ const flashVariantForVariant = (variant) => {
29
+ switch (variant) {
30
+ case 'info':
31
+ return 'default';
32
+ case 'critical':
33
+ return 'danger';
34
+ default:
35
+ return variant;
36
+ }
37
+ };
5
38
  const closeButtonToneForVariant = (variant) => {
6
39
  switch (variant) {
7
40
  case 'success':
@@ -9,7 +42,10 @@ const closeButtonToneForVariant = (variant) => {
9
42
  case 'warning':
10
43
  return { fg: 'attention.fg', hoverBg: 'rgba(154, 103, 0, 0.16)' };
11
44
  case 'danger':
45
+ case 'critical':
12
46
  return { fg: 'danger.fg', hoverBg: 'rgba(207, 34, 46, 0.14)' };
47
+ case 'info':
48
+ return { fg: 'accent.fg', hoverBg: 'rgba(9, 105, 218, 0.12)' };
13
49
  default:
14
50
  return { fg: 'fg.default', hoverBg: 'rgba(31, 35, 40, 0.12)' };
15
51
  }
@@ -21,16 +57,21 @@ const textColorForVariant = (variant) => {
21
57
  case 'warning':
22
58
  return 'attention.fg';
23
59
  case 'danger':
60
+ case 'critical':
24
61
  return 'danger.fg';
62
+ case 'info':
63
+ return 'accent.fg';
25
64
  default:
26
65
  return 'fg.default';
27
66
  }
28
67
  };
29
68
  export const CloseableFlash = (props) => {
30
- const { leadingVisual, variant, onClose, ...otherProps } = props;
69
+ const { leadingVisual, variant, onClose, sx, dismissible = true, ...otherProps } = props;
31
70
  const resolvedVariant = variant ?? 'default';
32
71
  const closeTone = closeButtonToneForVariant(resolvedVariant);
33
- const flashVariant = resolvedVariant === 'default' ? undefined : resolvedVariant;
72
+ const primerVariant = flashVariantForVariant(resolvedVariant);
73
+ const flashVariant = primerVariant === 'default' ? undefined : primerVariant;
74
+ const isCritical = resolvedVariant === 'critical';
34
75
  const [isVisible, setIsVisible] = useState(true);
35
76
  const handleClose = () => {
36
77
  if (onClose) {
@@ -41,7 +82,14 @@ export const CloseableFlash = (props) => {
41
82
  if (!isVisible) {
42
83
  return null;
43
84
  }
44
- return (_jsx(Flash, { variant: flashVariant, ...otherProps, children: _jsxs(Box, { display: "flex", justifyContent: "space-between", alignItems: "center", children: [_jsxs(Box, { sx: { flexGrow: 1 }, children: [leadingVisual && _jsx(CircleOcticon, { icon: leadingVisual }), _jsx(Text, { color: textColorForVariant(resolvedVariant), children: props.children })] }), _jsx(Box, { as: "button", type: "button", "aria-label": "Close", onClick: handleClose, sx: {
85
+ return (_jsx(Flash, { variant: flashVariant, sx: {
86
+ // A critical flash is the one a reader must not scroll past: the
87
+ // border carries the emphasis colour rather than the muted one.
88
+ ...(isCritical
89
+ ? { borderColor: 'danger.emphasis', borderWidth: 2, borderStyle: 'solid' }
90
+ : {}),
91
+ ...sx,
92
+ }, ...otherProps, children: _jsxs(Box, { display: "flex", justifyContent: "space-between", alignItems: "center", children: [_jsxs(Box, { sx: { flexGrow: 1 }, children: [leadingVisual && _jsx(CircleOcticon, { icon: leadingVisual }), _jsx(Text, { color: textColorForVariant(resolvedVariant), sx: isCritical ? { fontWeight: 'bold' } : undefined, children: props.children })] }), dismissible && (_jsx(Box, { as: "button", type: "button", "aria-label": "Close", onClick: handleClose, sx: {
45
93
  flex: '0 0 auto',
46
94
  p: 0,
47
95
  m: 0,
@@ -71,5 +119,5 @@ export const CloseableFlash = (props) => {
71
119
  '& svg': {
72
120
  display: 'block',
73
121
  },
74
- }, children: _jsx(Box, { as: "span", "aria-hidden": true, children: _jsx(XIcon, { size: 16 }) }) })] }) }));
122
+ }, children: _jsx(Box, { as: "span", "aria-hidden": true, children: _jsx(XIcon, { size: 16 }) }) }))] }) }));
75
123
  };
@@ -1,4 +1,8 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
+ /*
3
+ * Copyright (c) 2021-2026 Datalayer, Inc.
4
+ * Distributed under the terms of the Modified BSD License.
5
+ */
2
6
  import { Box, useTheme } from "@primer/react";
3
7
  import Skeleton, { SkeletonTheme } from "react-loading-skeleton";
4
8
  import 'react-loading-skeleton/dist/skeleton.css';
@@ -1,6 +1,10 @@
1
1
  import { type CalendarPickerProps } from "../calendar-picker/CalendarPicker";
2
2
  /** `YYYY-MM-DD`, the one format that means the same thing everywhere. */
3
3
  export declare function formatISODate(date: Date): string;
4
+ /** `YYYY-MM-DD HH:mm`, the same date with the minute it holds. */
5
+ export declare function formatISODateTime(date: Date): string;
6
+ /** `HH:mm` of a date, which is what a `type="time"` field holds. */
7
+ export declare function formatTime(date: Date): string;
4
8
  /**
5
9
  * Reads `YYYY-MM-DD` as a local date.
6
10
  *
@@ -9,6 +13,16 @@ export declare function formatISODate(date: Date): string;
9
13
  * means the same day in every zone.
10
14
  */
11
15
  export declare function parseISODate(text: string): Date | null;
16
+ /**
17
+ * Reads `YYYY-MM-DD HH:mm` — or the same with a `T`, or with seconds, or with
18
+ * no time at all — as a local date.
19
+ *
20
+ * Lenient on the way in and strict on the way out: somebody typing a date into
21
+ * a field has a shape in their fingers, and rejecting `2026-01-31T09:00`
22
+ * because the separator is a `T` teaches them nothing. What the field *writes*
23
+ * is always one shape.
24
+ */
25
+ export declare function parseISODateTime(text: string): Date | null;
12
26
  export interface DatePickerProps extends Pick<CalendarPickerProps, "min" | "max" | "isDisabled" | "weekStartsOn" | "locale"> {
13
27
  /** The chosen date, or nothing chosen. */
14
28
  value?: Date | null;
@@ -35,6 +49,27 @@ export interface DatePickerProps extends Pick<CalendarPickerProps, "min" | "max"
35
49
  format?: (date: Date) => string;
36
50
  /** An id for the field, for a label that names it from outside. */
37
51
  id?: string;
52
+ /**
53
+ * Whether the field holds a time as well as a day.
54
+ *
55
+ * With it, the field reads and writes `YYYY-MM-DD HH:mm`, and the overlay
56
+ * carries a time beneath the calendar. Without it — the default — nothing
57
+ * about the field changes, because most dates are days.
58
+ */
59
+ withTime?: boolean;
60
+ /**
61
+ * Minutes between the options the minute column offers. Default: five.
62
+ *
63
+ * A minute the step does not land on — one read back from a stored value —
64
+ * is still offered, so choosing an hour never quietly moves it.
65
+ */
66
+ timeStep?: number;
67
+ /**
68
+ * The time a day picked from the calendar takes when the field held nothing
69
+ * yet. `"00:00"` by default — a window that starts "on the 3rd" starts at
70
+ * the beginning of the 3rd.
71
+ */
72
+ defaultTime?: string;
38
73
  }
39
74
  /**
40
75
  * A date field with a calendar behind it.
@@ -42,5 +77,5 @@ export interface DatePickerProps extends Pick<CalendarPickerProps, "min" | "max"
42
77
  * Controlled: `value` is the caller's, and every pick and every parsed entry
43
78
  * arrives through `onChange`.
44
79
  */
45
- export declare function DatePicker({ value, onChange, placeholder, "aria-label": ariaLabel, disabled, size, validationStatus, format, id, min, max, isDisabled, weekStartsOn, locale, }: DatePickerProps): import("react").JSX.Element;
80
+ export declare function DatePicker({ value, onChange, placeholder, "aria-label": ariaLabel, disabled, size, validationStatus, format, id, min, max, isDisabled, weekStartsOn, locale, withTime, timeStep, defaultTime, }: DatePickerProps): import("react").JSX.Element;
46
81
  export default DatePicker;
@@ -1,14 +1,27 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useEffect, useMemo, useRef, useState } from "react";
3
- import { AnchoredOverlay, Box, TextInput } from "@primer/react";
3
+ import { AnchoredOverlay, Box, Button, TextInput } from "@primer/react";
4
4
  import { CalendarIcon } from "@primer/octicons-react";
5
5
  import { CalendarPicker } from "../calendar-picker/CalendarPicker";
6
+ import { TimeColumns } from "./TimeColumns";
6
7
  /** `YYYY-MM-DD`, the one format that means the same thing everywhere. */
7
8
  export function formatISODate(date) {
8
9
  const month = `${date.getMonth() + 1}`.padStart(2, "0");
9
10
  const day = `${date.getDate()}`.padStart(2, "0");
10
11
  return `${date.getFullYear()}-${month}-${day}`;
11
12
  }
13
+ /** `YYYY-MM-DD HH:mm`, the same date with the minute it holds. */
14
+ export function formatISODateTime(date) {
15
+ const hours = `${date.getHours()}`.padStart(2, "0");
16
+ const minutes = `${date.getMinutes()}`.padStart(2, "0");
17
+ return `${formatISODate(date)} ${hours}:${minutes}`;
18
+ }
19
+ /** `HH:mm` of a date, which is what a `type="time"` field holds. */
20
+ export function formatTime(date) {
21
+ const hours = `${date.getHours()}`.padStart(2, "0");
22
+ const minutes = `${date.getMinutes()}`.padStart(2, "0");
23
+ return `${hours}:${minutes}`;
24
+ }
12
25
  /**
13
26
  * Reads `YYYY-MM-DD` as a local date.
14
27
  *
@@ -23,7 +36,44 @@ export function parseISODate(text) {
23
36
  }
24
37
  const [, year, month, day] = match;
25
38
  const date = new Date(Number(year), Number(month) - 1, Number(day));
26
- return Number.isNaN(date.getTime()) ? null : date;
39
+ return isExactly(date, Number(year), Number(month), Number(day)) ? date : null;
40
+ }
41
+ /**
42
+ * Whether `date` holds exactly the fields it was built from.
43
+ *
44
+ * The `Date` constructor rolls an out-of-range field into the next one —
45
+ * `2026-02-31` becomes the 3rd of March, `24:00` the next day — instead of
46
+ * failing, so a typo would be committed as some other moment. A field that
47
+ * does not come back as written was not a real one.
48
+ */
49
+ function isExactly(date, year, month, day, hours = 0, minutes = 0, seconds = 0) {
50
+ return (!Number.isNaN(date.getTime()) &&
51
+ date.getFullYear() === year &&
52
+ date.getMonth() === month - 1 &&
53
+ date.getDate() === day &&
54
+ date.getHours() === hours &&
55
+ date.getMinutes() === minutes &&
56
+ date.getSeconds() === seconds);
57
+ }
58
+ /**
59
+ * Reads `YYYY-MM-DD HH:mm` — or the same with a `T`, or with seconds, or with
60
+ * no time at all — as a local date.
61
+ *
62
+ * Lenient on the way in and strict on the way out: somebody typing a date into
63
+ * a field has a shape in their fingers, and rejecting `2026-01-31T09:00`
64
+ * because the separator is a `T` teaches them nothing. What the field *writes*
65
+ * is always one shape.
66
+ */
67
+ export function parseISODateTime(text) {
68
+ const match = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{1,2}):(\d{2})(?::(\d{2}))?)?$/.exec(text.trim());
69
+ if (!match) {
70
+ return null;
71
+ }
72
+ const [, year, month, day, hours, minutes, seconds] = match;
73
+ const date = new Date(Number(year), Number(month) - 1, Number(day), Number(hours ?? 0), Number(minutes ?? 0), Number(seconds ?? 0));
74
+ return isExactly(date, Number(year), Number(month), Number(day), Number(hours ?? 0), Number(minutes ?? 0), Number(seconds ?? 0))
75
+ ? date
76
+ : null;
27
77
  }
28
78
  /**
29
79
  * A date field with a calendar behind it.
@@ -31,18 +81,23 @@ export function parseISODate(text) {
31
81
  * Controlled: `value` is the caller's, and every pick and every parsed entry
32
82
  * arrives through `onChange`.
33
83
  */
34
- export function DatePicker({ value = null, onChange, placeholder = "YYYY-MM-DD", "aria-label": ariaLabel = "Date", disabled = false, size = "medium", validationStatus, format = formatISODate, id, min, max, isDisabled, weekStartsOn, locale, }) {
84
+ export function DatePicker({ value = null, onChange, placeholder, "aria-label": ariaLabel = "Date", disabled = false, size = "medium", validationStatus, format, id, min, max, isDisabled, weekStartsOn, locale, withTime = false, timeStep = 5, defaultTime = "00:00", }) {
85
+ // The two shapes, chosen once: a caller that passed a `format` keeps it,
86
+ // and a caller that asked for a time gets the one that carries a time.
87
+ const writeDate = format ?? (withTime ? formatISODateTime : formatISODate);
88
+ const readDate = withTime ? parseISODateTime : parseISODate;
89
+ const emptyText = placeholder ?? (withTime ? "YYYY-MM-DD HH:mm" : "YYYY-MM-DD");
35
90
  const [open, setOpen] = useState(false);
36
- const [text, setText] = useState(() => (value ? format(value) : ""));
91
+ const [text, setText] = useState(() => (value ? writeDate(value) : ""));
37
92
  const anchorRef = useRef(null);
38
93
  // The field follows the value, except while it is being typed into: a
39
94
  // controlled rewrite mid-word moves the caret to the end.
40
95
  const typing = useRef(false);
41
96
  useEffect(() => {
42
97
  if (!typing.current) {
43
- setText(value ? format(value) : "");
98
+ setText(value ? writeDate(value) : "");
44
99
  }
45
- }, [value, format]);
100
+ }, [value, writeDate]);
46
101
  const commit = (next) => {
47
102
  typing.current = false;
48
103
  const trimmed = next.trim();
@@ -50,38 +105,89 @@ export function DatePicker({ value = null, onChange, placeholder = "YYYY-MM-DD",
50
105
  onChange?.(null);
51
106
  return;
52
107
  }
53
- const parsed = parseISODate(trimmed);
108
+ const parsed = readDate(trimmed);
54
109
  if (parsed) {
55
110
  onChange?.(parsed);
56
- setText(format(parsed));
111
+ setText(writeDate(parsed));
57
112
  }
58
113
  else {
59
114
  // Unreadable: put back what is actually held, rather than keeping a
60
115
  // date on screen that the caller never received.
61
- setText(value ? format(value) : "");
116
+ setText(value ? writeDate(value) : "");
62
117
  }
63
118
  };
64
- const calendar = useMemo(() => (_jsx(Box, { sx: { p: 3 }, children: _jsx(CalendarPicker, { value: value, defaultMonth: value ?? undefined, onChange: (date) => {
65
- onChange?.(date);
66
- setText(format(date));
67
- setOpen(false);
68
- }, min: min, max: max, isDisabled: isDisabled, weekStartsOn: weekStartsOn, locale: locale, "aria-label": `${ariaLabel}, choose a day` }) })), [
119
+ /** The day picked, wearing the time the field already held. */
120
+ const withHeldTime = (day) => {
121
+ const [hours, minutes] = (value ? formatTime(value) : defaultTime)
122
+ .split(":")
123
+ .map(Number);
124
+ const next = new Date(day);
125
+ next.setHours(hours || 0, minutes || 0, 0, 0);
126
+ return next;
127
+ };
128
+ /** The time the field holds right now, or the one a new pick starts from. */
129
+ const held = (() => {
130
+ const [hours, minutes] = (value ? formatTime(value) : defaultTime)
131
+ .split(":")
132
+ .map(Number);
133
+ return { hour: hours || 0, minute: minutes || 0 };
134
+ })();
135
+ /** The day the field holds, at the time just chosen. */
136
+ const atTime = (hour, minute) => {
137
+ const next = new Date(value ?? new Date());
138
+ next.setHours(hour, minute, 0, 0);
139
+ return next;
140
+ };
141
+ const calendar = useMemo(() => (_jsxs(Box, { sx: { p: 3 }, children: [_jsx(CalendarPicker, { value: value, defaultMonth: value ?? undefined, onChange: (date) => {
142
+ // A day picked while a time is held keeps that time: somebody
143
+ // moving a window from the 3rd to the 4th did not mean to move
144
+ // it to midnight.
145
+ const next = withTime ? withHeldTime(date) : date;
146
+ onChange?.(next);
147
+ setText(writeDate(next));
148
+ if (!withTime) {
149
+ setOpen(false);
150
+ }
151
+ }, min: min, max: max, isDisabled: isDisabled, weekStartsOn: weekStartsOn, locale: locale, "aria-label": `${ariaLabel}, choose a day` }), withTime && (_jsxs(Box, { sx: {
152
+ mt: 3,
153
+ pt: 3,
154
+ borderTop: "1px solid",
155
+ borderColor: "border.default",
156
+ }, children: [_jsx(TimeColumns, { hour: held.hour, minute: held.minute, step: timeStep, disabled: disabled, labelPrefix: ariaLabel, onChange: (hour, minute) => {
157
+ const next = atTime(hour, minute);
158
+ onChange?.(next);
159
+ setText(writeDate(next));
160
+ } }), _jsx(Box, { sx: {
161
+ display: "flex",
162
+ alignItems: "center",
163
+ justifyContent: "flex-end",
164
+ gap: 2,
165
+ mt: 2,
166
+ }, children: _jsx(Button, { size: "small", variant: "primary", onClick: () => setOpen(false), children: "Done" }) })] }))] })),
167
+ // `withHeldTime`, `held` and `atTime` all close over `value` and
168
+ // `defaultTime`, both of which are named here.
169
+ [
69
170
  ariaLabel,
70
- format,
171
+ defaultTime,
172
+ disabled,
71
173
  isDisabled,
72
174
  locale,
73
175
  max,
74
176
  min,
75
177
  onChange,
178
+ size,
179
+ timeStep,
76
180
  value,
77
181
  weekStartsOn,
182
+ withTime,
183
+ writeDate,
78
184
  ]);
79
185
  return (_jsxs(Box, { ref: anchorRef, sx: { display: "inline-block" }, children: [_jsx(AnchoredOverlay, { open: open, onOpen: () => !disabled && setOpen(true), onClose: () => setOpen(false),
80
186
  // The field's own box is the anchor; `useRef` types its current as
81
187
  // nullable, which is not how the overlay declares what it takes.
82
188
  anchorRef: anchorRef,
83
189
  // The field is the anchor; the button inside it does the opening.
84
- renderAnchor: null, overlayProps: { role: "dialog", "aria-label": `${ariaLabel} calendar` }, children: calendar }), _jsx(TextInput, { id: id, "aria-label": ariaLabel, "aria-haspopup": "dialog", "aria-expanded": open, value: text, placeholder: placeholder, disabled: disabled, size: size, validationStatus: validationStatus, onChange: (event) => {
190
+ renderAnchor: null, overlayProps: { role: "dialog", "aria-label": `${ariaLabel} calendar` }, children: calendar }), _jsx(TextInput, { id: id, "aria-label": ariaLabel, "aria-haspopup": "dialog", "aria-expanded": open, value: text, placeholder: emptyText, disabled: disabled, size: size, validationStatus: validationStatus, onChange: (event) => {
85
191
  typing.current = true;
86
192
  setText(event.target.value);
87
193
  }, onBlur: (event) => commit(event.target.value), onKeyDown: (event) => {
@@ -0,0 +1,23 @@
1
+ /** `7` → `07`. Two digits, always, because a column of ragged numbers reads badly. */
2
+ export declare const pad: (value: number) => string;
3
+ export interface TimeColumnsProps {
4
+ /** The hour shown as chosen, 0–23. */
5
+ hour: number;
6
+ /** The minute shown as chosen, 0–59. */
7
+ minute: number;
8
+ /** Minutes between the options offered. 1 lists all sixty. */
9
+ step?: number;
10
+ /** Told when either column is used, with the whole time. */
11
+ onChange: (hour: number, minute: number) => void;
12
+ disabled?: boolean;
13
+ /** Names the columns for a screen reader: "Start date, hour". Not drawn —
14
+ * on screen the two columns are headed "Hour" and "Minute", and a whole
15
+ * field label repeated over them reads as noise. */
16
+ labelPrefix?: string;
17
+ }
18
+ /**
19
+ * The two columns, side by side, with the time they currently mean between
20
+ * them.
21
+ */
22
+ export declare function TimeColumns({ hour, minute, step, onChange, disabled, labelPrefix, }: TimeColumnsProps): import("react").JSX.Element;
23
+ export default TimeColumns;
@@ -0,0 +1,88 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /*
3
+ * Copyright (c) 2023-2026 Datalayer, Inc.
4
+ * Distributed under the terms of the Modified BSD License.
5
+ */
6
+ /**
7
+ * Hours and minutes, as two columns the reader scrolls.
8
+ *
9
+ * The browser's own `type="time"` control draws its dropdown itself: unstyled,
10
+ * unaware of the page's theme, and — because it is the platform's own popup —
11
+ * *over* whatever is already open. Beside a calendar that means the time
12
+ * dropdown covers the days, which is the one thing somebody choosing a moment
13
+ * needs to keep seeing.
14
+ *
15
+ * So the columns are drawn here, in Primer, inside the overlay that already
16
+ * exists. Not a second overlay: a menu anchored inside an `AnchoredOverlay`
17
+ * portals out of it, and the outer overlay's own click-away closes everything
18
+ * the moment the pointer lands in the inner one.
19
+ *
20
+ * @module components/date-picker/TimeColumns
21
+ */
22
+ import { useEffect, useRef } from "react";
23
+ import { ActionList, Box, Text } from "@primer/react";
24
+ /** `7` → `07`. Two digits, always, because a column of ragged numbers reads badly. */
25
+ export const pad = (value) => `${value}`.padStart(2, "0");
26
+ const HOURS = Array.from({ length: 24 }, (_, index) => index);
27
+ function Column({ label, ariaLabel, values, selected, onPick, disabled, }) {
28
+ const scroller = useRef(null);
29
+ const chosen = useRef(null);
30
+ // The chosen value, in view, the moment the column is drawn. A column that
31
+ // opens at midnight when it holds 17:00 makes the reader scroll to find out
32
+ // what it is already set to.
33
+ useEffect(() => {
34
+ const item = chosen.current;
35
+ if (!item) {
36
+ return;
37
+ }
38
+ // After layout, and against the scroller rather than whatever the list
39
+ // happens to be positioned against.
40
+ const frame = requestAnimationFrame(() => {
41
+ item.scrollIntoView({ block: "center", inline: "nearest" });
42
+ });
43
+ return () => cancelAnimationFrame(frame);
44
+ }, [selected]);
45
+ return (_jsxs(Box, { sx: { flex: 1, minWidth: 0 }, children: [_jsx(Text, { sx: {
46
+ display: "block",
47
+ fontSize: 0,
48
+ color: "fg.muted",
49
+ mb: 1,
50
+ textAlign: "center",
51
+ }, children: label }), _jsx(Box, { ref: scroller, sx: {
52
+ height: 132,
53
+ overflowY: "auto",
54
+ border: "1px solid",
55
+ borderColor: "border.default",
56
+ borderRadius: 2,
57
+ bg: "canvas.default",
58
+ // The scrollbar is the only hint that there is more below, so it
59
+ // stays: a hidden one turns the column into a list of six hours.
60
+ scrollbarWidth: "thin",
61
+ }, children: _jsx(ActionList, { selectionVariant: "single", "aria-label": ariaLabel, sx: { p: 1 }, children: values.map((value) => (_jsx(ActionList.Item, { ref: value === selected ? chosen : undefined, selected: value === selected, disabled: disabled, onSelect: () => onPick(value), sx: { justifyContent: "center", fontVariantNumeric: "tabular-nums" }, children: pad(value) }, value))) }) })] }));
62
+ }
63
+ /**
64
+ * The two columns, side by side, with the time they currently mean between
65
+ * them.
66
+ */
67
+ export function TimeColumns({ hour, minute, step = 5, onChange, disabled, labelPrefix, }) {
68
+ const every = Math.min(60, Math.max(1, Math.floor(step) || 1));
69
+ const minutes = Array.from({ length: Math.ceil(60 / every) }, (_, index) => index * every);
70
+ // A minute the step does not land on — 17:07 read back from a stored value —
71
+ // still has to be selectable, or choosing an hour would silently move it.
72
+ const shown = minutes.includes(minute)
73
+ ? minutes
74
+ : [...minutes, minute].sort((left, right) => left - right);
75
+ return (_jsxs(Box, { children: [_jsxs(Box, { sx: {
76
+ display: "flex",
77
+ alignItems: "baseline",
78
+ justifyContent: "space-between",
79
+ gap: 2,
80
+ mb: 1,
81
+ }, children: [_jsx(Text, { sx: { fontSize: 0, color: "fg.muted" }, children: "Time" }), _jsxs(Text, { sx: {
82
+ fontFamily: "mono",
83
+ fontSize: 1,
84
+ fontWeight: "bold",
85
+ fontVariantNumeric: "tabular-nums",
86
+ }, children: [pad(hour), ":", pad(minute)] })] }), _jsxs(Box, { sx: { display: "flex", gap: 2 }, children: [_jsx(Column, { label: "Hour", ariaLabel: labelPrefix ? `${labelPrefix}, hour` : "Hour", values: HOURS, selected: hour, disabled: disabled, onPick: (next) => onChange(next, minute) }), _jsx(Column, { label: "Minute", ariaLabel: labelPrefix ? `${labelPrefix}, minute` : "Minute", values: shown, selected: minute, disabled: disabled, onPick: (next) => onChange(hour, next) })] })] }));
87
+ }
88
+ export default TimeColumns;
@@ -1,4 +1,8 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
+ /*
3
+ * Copyright (c) 2021-2026 Datalayer, Inc.
4
+ * Distributed under the terms of the Modified BSD License.
5
+ */
2
6
  import { useTheme } from '@primer/react';
3
7
  import { CircleCurrentColorIcon } from '@datalayer/icons-react';
4
8
  export const CircleIcon = ({ color = 'white', variant = 'fg', }) => {
@@ -1,3 +1,7 @@
1
+ /*
2
+ * Copyright (c) 2021-2026 Datalayer, Inc.
3
+ * Distributed under the terms of the Modified BSD License.
4
+ */
1
5
  export * from "./appearance/AppearanceControls";
2
6
  export * from "./appearance/AppearanceControlsWithStore";
3
7
  export * from "./appearance/AppearanceMenu";
@@ -1,3 +1,7 @@
1
+ /*
2
+ * Copyright (c) 2021-2026 Datalayer, Inc.
3
+ * Distributed under the terms of the Modified BSD License.
4
+ */
1
5
  export * from "./DatalayerLogo";
2
6
  export * from "./DatalayerLogoText";
3
7
  export * from "./DatalayerText";
@@ -1,4 +1,8 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ /*
3
+ * Copyright (c) 2021-2026 Datalayer, Inc.
4
+ * Distributed under the terms of the Modified BSD License.
5
+ */
2
6
  import { useEffect, useState } from 'react';
3
7
  import { IconButton, Overlay as PrimerOverlay, ThemeProvider, useTheme } from "@primer/react";
4
8
  import { XIcon } from "@primer/octicons-react";
@@ -1,4 +1,8 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /*
3
+ * Copyright (c) 2021-2026 Datalayer, Inc.
4
+ * Distributed under the terms of the Modified BSD License.
5
+ */
2
6
  import { useState, useRef } from "react";
3
7
  import { Heading, Text, Box, Button } from "@primer/react";
4
8
  import { SideOverlay } from '../..';
@@ -1,4 +1,8 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /*
3
+ * Copyright (c) 2021-2026 Datalayer, Inc.
4
+ * Distributed under the terms of the Modified BSD License.
5
+ */
2
6
  import { useEffect, useState } from "react";
3
7
  import { useTheme, Box, CounterLabel, FormControl } from "@primer/react";
4
8
  export const Slider = ({ name = "slider", id = "slider", min = 0, max = 10, value = 5, label = "label", step = 1, markers = false, displayValue = true, disabled = false, orientation = "horizontal", width = "200px", onChange, }) => {
@@ -1,4 +1,8 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /*
3
+ * Copyright (c) 2021-2026 Datalayer, Inc.
4
+ * Distributed under the terms of the Modified BSD License.
5
+ */
2
6
  import { useState } from 'react';
3
7
  import { Box, CounterLabel } from "@primer/react";
4
8
  import { Slider } from '../../index';
@@ -1,4 +1,8 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /*
3
+ * Copyright (c) 2021-2026 Datalayer, Inc.
4
+ * Distributed under the terms of the Modified BSD License.
5
+ */
2
6
  import { useEffect, useMemo, useState } from 'react';
3
7
  import { IconButton } from '@primer/react';
4
8
  import { XIcon } from '@primer/octicons-react';
@@ -169,7 +173,19 @@ export const SlidingPanel = ({ isOpen, message, details, onDismiss, position = '
169
173
  display: 'flex',
170
174
  pointerEvents: 'none',
171
175
  ...anchorStyles,
172
- }, children: _jsxs(Box, { role: variant === 'error' || variant === 'danger' ? 'alert' : 'status', "aria-live": variant === 'error' || variant === 'danger' ? 'assertive' : 'polite', onClick: onDismiss, sx: {
176
+ }, children: _jsxs(Box, { role: variant === 'error' || variant === 'danger' ? 'alert' : 'status', "aria-live": variant === 'error' || variant === 'danger' ? 'assertive' : 'polite', onClick: onDismiss
177
+ ? event => {
178
+ // A tap on the panel dismisses it — but not a click on a
179
+ // link, button or field in its content, nor the end of a
180
+ // text selection: those are someone using what it says.
181
+ const target = event.target;
182
+ if (target.closest('a, button, input, select, textarea, label, [role="button"], [contenteditable="true"]') ||
183
+ (window.getSelection()?.toString() ?? '') !== '') {
184
+ return;
185
+ }
186
+ onDismiss();
187
+ }
188
+ : undefined, sx: {
173
189
  pointerEvents: 'auto',
174
190
  position: 'relative',
175
191
  display: 'flex',
@@ -1,4 +1,8 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /*
3
+ * Copyright (c) 2021-2026 Datalayer, Inc.
4
+ * Distributed under the terms of the Modified BSD License.
5
+ */
2
6
  import { useRef, useState } from 'react';
3
7
  import { Box, Button, Heading } from '@primer/react';
4
8
  import { SlidingPanel } from './SlidingPanel';
package/lib/index.js CHANGED
@@ -1,3 +1,7 @@
1
+ /*
2
+ * Copyright (c) 2021-2026 Datalayer, Inc.
3
+ * Distributed under the terms of the Modified BSD License.
4
+ */
1
5
  export * from "./components";
2
6
  export * from "./utils";
3
7
  export * from './theme';
@@ -112,7 +112,6 @@ function ActivityLine({ label }) {
112
112
  } }), _jsx(Text, { children: label })] }));
113
113
  }
114
114
  export function PageLayout({ page, hasPage = page !== undefined && page !== null, panel, band, chips, picker, transient, activity, bandMode = "docked", panelMode = "docked", panelSide = "right", sheetWidth = PAGE_SHEET_WIDTH, pageSize, panelWidth = PAGE_PANEL_WIDTH, }) {
115
- const floating = bandMode === "floating";
116
115
  const bandInPanel = bandMode === "panel";
117
116
  const hasBand = band !== undefined && band !== null;
118
117
  // One width for the sheet, the band's mount and the chips: the column.
@@ -238,73 +237,90 @@ export function PageLayout({ page, hasPage = page !== undefined && page !== null
238
237
  pointerEvents: "none",
239
238
  zIndex: -1,
240
239
  display: "flex",
241
- }, children: page }))] })] }), !hasPage || !panel ? null : panelShown ? (_jsxs(Box, { "data-page-panel": "", "data-page-panel-mode": panelMode, sx: {
240
+ }, children: page }))] })] }), !hasPage || !panel ? null : (_jsxs(Box, { "data-page-panel": panelShown ? "" : undefined, "data-page-panel-mode": panelMode, "aria-hidden": panelShown ? undefined : "true", sx: {
242
241
  minWidth: 0,
243
242
  minHeight: 0,
244
243
  display: "flex",
245
244
  flexDirection: "column",
246
245
  bg: "canvas.default",
247
246
  borderColor: "border.default",
248
- ...(panelMode === "docked"
247
+ ...(!panelShown
249
248
  ? {
250
- flex: `0 0 ${panelWidth}px`,
251
- maxWidth: "45%",
252
- order: panelSide === "left" ? -1 : undefined,
253
- [panelSide === "left" ? "borderRight" : "borderLeft"]: "1px solid",
254
- // Over the canvas, and beside the band rather than under
255
- // it: the band belongs to the page column.
256
- zIndex: 1,
249
+ // Out of sight, and out of the way of the pointer, while
250
+ // whatever is in it goes on running. No `z-index` here:
251
+ // it would make a stacking context, and the composer —
252
+ // which lives in this column whether the panel is open or
253
+ // not — has to be able to float over the page from it.
254
+ position: "absolute",
255
+ inset: 0,
256
+ visibility: "hidden",
257
+ pointerEvents: "none",
258
+ overflow: "visible",
257
259
  }
258
- : panelMode === "overlay"
260
+ : panelMode === "docked"
259
261
  ? {
260
- // Over the page's right edge, the page untouched
261
- // under it; against the root, as the float is.
262
- position: "absolute",
263
- top: 0,
264
- bottom: 0,
265
- [panelSide]: 0,
266
- width: panelWidth,
267
- maxWidth: "85%",
262
+ flex: `0 0 ${panelWidth}px`,
263
+ maxWidth: "45%",
264
+ order: panelSide === "left" ? -1 : undefined,
268
265
  [panelSide === "left" ? "borderRight" : "borderLeft"]: "1px solid",
269
- boxShadow: "shadow.large",
270
- zIndex: 5,
266
+ // Over the canvas, and beside the band rather than under
267
+ // it: the band belongs to the page column.
268
+ zIndex: 1,
271
269
  }
272
- : {
273
- // A window in the page's corner.
274
- position: "absolute",
275
- [panelSide]: 16,
276
- bottom: 16,
277
- width: panelWidth,
278
- maxWidth: "calc(100% - 32px)",
279
- height: "min(70%, 560px)",
280
- border: "1px solid",
281
- borderRadius: 2,
282
- boxShadow: "shadow.large",
283
- overflow: "hidden",
284
- zIndex: 5,
285
- }),
270
+ : panelMode === "overlay"
271
+ ? {
272
+ // Over the page's right edge, the page untouched
273
+ // under it; against the root, as the float is.
274
+ position: "absolute",
275
+ top: 0,
276
+ bottom: 0,
277
+ [panelSide]: 0,
278
+ width: panelWidth,
279
+ maxWidth: "85%",
280
+ [panelSide === "left" ? "borderRight" : "borderLeft"]: "1px solid",
281
+ boxShadow: "shadow.large",
282
+ zIndex: 5,
283
+ }
284
+ : {
285
+ // A window in the page's corner.
286
+ position: "absolute",
287
+ [panelSide]: 16,
288
+ bottom: 16,
289
+ width: panelWidth,
290
+ maxWidth: "calc(100% - 32px)",
291
+ height: "min(70%, 560px)",
292
+ border: "1px solid",
293
+ borderRadius: 2,
294
+ boxShadow: "shadow.large",
295
+ overflow: "hidden",
296
+ zIndex: 5,
297
+ }),
286
298
  }, children: [_jsx(Box, { sx: {
287
299
  flex: "1 1 auto",
288
300
  minHeight: 0,
289
301
  display: "flex",
290
302
  flexDirection: "column",
291
303
  "& > *": { flex: "1 1 auto", minHeight: 0 },
292
- }, children: panel }), bandInPanel && hasBand ? (_jsxs(Box, { "data-page-panel-band": "", sx: {
293
- flex: "0 0 auto",
294
- borderTop: "1px solid",
295
- borderColor: "border.default",
296
- "& > *": { justifyContent: "center" },
297
- }, children: [chips, band] })) : null] })) : (
298
- // Kept mounted, out of sight: a conversation is where the tools
299
- // run and the stream lands, and a panel that unmounted it would
300
- // stop the agent the moment the reader closed it.
301
- _jsx(Box, { "aria-hidden": "true", sx: {
302
- position: "absolute",
303
- inset: 0,
304
- visibility: "hidden",
305
- pointerEvents: "none",
306
- zIndex: -1,
307
- display: "flex",
308
- }, children: panel }))] }), transient, floating && hasBand ? band : null] }));
304
+ }, children: panel }), hasBand && bandMode !== "docked" ? (_jsxs(Box, { "data-page-panel-band": bandInPanel ? "" : undefined, sx: bandInPanel
305
+ ? {
306
+ flex: "0 0 auto",
307
+ borderTop: "1px solid",
308
+ borderColor: "border.default",
309
+ // Visible even while the column itself is hidden —
310
+ // which it is until something opens the panel.
311
+ visibility: "visible",
312
+ pointerEvents: "auto",
313
+ "& > *": { justifyContent: "center" },
314
+ }
315
+ : {
316
+ /* The card positions itself against this layout's
317
+ root; this only has to stay out of its way, and
318
+ stay visible when the column around it is not. */
319
+ position: "absolute",
320
+ inset: 0,
321
+ visibility: "visible",
322
+ pointerEvents: "none",
323
+ "& > *": { pointerEvents: "auto" },
324
+ }, children: [bandInPanel ? chips : null, band] })) : null] }))] }), transient] }));
309
325
  }
310
326
  export default PageLayout;
@@ -8,7 +8,7 @@ type StoryContext = Record<string, unknown> & {
8
8
  parameters: Record<string, unknown>;
9
9
  };
10
10
  export declare const colormodeFromScheme: (colorScheme: string) => "light" | "dark";
11
- export declare const withThemeProvider: (Story: React.FC<React.PropsWithChildren<StoryContext>>, context: StoryContext) => string | number | bigint | boolean | Iterable<React.ReactNode> | React.JSX.Element | Promise<React.ReactNode> | null | undefined;
11
+ export declare const withThemeProvider: (Story: React.FC<React.PropsWithChildren<StoryContext>>, context: StoryContext) => string | number | bigint | boolean | Iterable<React.ReactNode> | Promise<React.ReactNode> | React.JSX.Element | null | undefined;
12
12
  export declare const toolbarTypes: {
13
13
  colorScheme: {
14
14
  name: string;
@@ -1,8 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  /*
3
3
  * Copyright (c) 2021-2024 Datalayer, Inc.
4
- *
5
- * Datalayer License
4
+ * Distributed under the terms of the Modified BSD License.
6
5
  */
7
6
  /**
8
7
  * Shared palette types, theme-aware colour resolution, and reusable
@@ -1,6 +1,5 @@
1
1
  /*
2
2
  * Copyright (c) 2021-2024 Datalayer, Inc.
3
- *
4
- * Datalayer License
3
+ * Distributed under the terms of the Modified BSD License.
5
4
  */
6
5
  export * from './ColorPalette';
@@ -119,7 +119,9 @@ export const setupPrimerPortals = (colormode) => {
119
119
  * `setupPrimerPortals` creates it; before that call there is none, and this
120
120
  * answers `null` rather than inventing one.
121
121
  */
122
- export const getPrimerPortalRoot = () => document.getElementById(PRIMER_PORTAL_ROOT_ID);
122
+ export const getPrimerPortalRoot = () => typeof document === 'undefined'
123
+ ? null
124
+ : document.getElementById(PRIMER_PORTAL_ROOT_ID);
123
125
  /* ─── camelCase → kebab-case ─────────────────────────────────────────── */
124
126
  const camelToKebab = (s) => s.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`);
125
127
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@datalayer/primer-addons",
3
- "version": "1.0.22",
3
+ "version": "1.0.23",
4
4
  "license": "BSD-3-Clause",
5
5
  "scripts": {
6
6
  "build": "npm run build:lib",
@@ -37,13 +37,19 @@
37
37
  "@primer/octicons-react": "^19.35.0",
38
38
  "@primer/primitives": "^10.7.0",
39
39
  "@primer/react": "^37.31.0",
40
- "react": "19.2.8",
41
- "react-dom": "19.2.8",
40
+ "@primer/react-brand": "^0.58.2",
42
41
  "react-loading-skeleton": "^3.5.0",
43
42
  "styled-components": "^5.3.10",
44
43
  "zustand": "^5.0.0"
45
44
  },
45
+ "peerDependencies": {
46
+ "react": "^18.3.1 || ^19.0.0",
47
+ "react-dom": "^18.3.1 || ^19.0.0"
48
+ },
46
49
  "devDependencies": {
50
+ "react": "19.2.8",
51
+ "react-dom": "19.2.8",
52
+ "rimraf": "^6.0.1",
47
53
  "@storybook/addon-docs": "^9.0.15",
48
54
  "@storybook/addon-essentials": "9.0.0-alpha.12",
49
55
  "@storybook/addon-interactions": "^9.0.0-alpha.10",