@byline/ui 4.14.1 → 4.16.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.
@@ -0,0 +1,6 @@
1
+ import type React from 'react';
2
+ import type { IconProps } from './types/icon.js';
3
+ export declare function AnalyticsIcon({ className, svgClassName, ...rest }: IconProps): React.JSX.Element;
4
+ export declare namespace AnalyticsIcon {
5
+ var displayName: string;
6
+ }
@@ -0,0 +1,39 @@
1
+ import { jsx, jsxs } from "react/jsx-runtime";
2
+ import clsx from "clsx";
3
+ import { IconElement } from "./icon-element.js";
4
+ import icons_module from "./icons.module.js";
5
+ const AnalyticsIcon = ({ className, svgClassName, ...rest })=>{
6
+ const applied = clsx(icons_module["fill-none"], icons_module["stroke-current"], svgClassName);
7
+ return /*#__PURE__*/ jsx(IconElement, {
8
+ className: clsx('analytics-icon', className),
9
+ ...rest,
10
+ children: /*#__PURE__*/ jsxs("svg", {
11
+ className: applied,
12
+ xmlns: "http://www.w3.org/2000/svg",
13
+ focusable: "false",
14
+ "aria-hidden": "true",
15
+ viewBox: "0 0 24 24",
16
+ fill: "none",
17
+ stroke: "currentColor",
18
+ strokeLinecap: "round",
19
+ strokeLinejoin: "round",
20
+ strokeWidth: 1.75,
21
+ children: [
22
+ /*#__PURE__*/ jsx("path", {
23
+ d: "M3 3v16a2 2 0 0 0 2 2h16"
24
+ }),
25
+ /*#__PURE__*/ jsx("path", {
26
+ d: "M8 20v-4"
27
+ }),
28
+ /*#__PURE__*/ jsx("path", {
29
+ d: "M13 20v-9"
30
+ }),
31
+ /*#__PURE__*/ jsx("path", {
32
+ d: "M18 20v-13"
33
+ })
34
+ ]
35
+ })
36
+ });
37
+ };
38
+ AnalyticsIcon.displayName = 'AnalyticsIcon';
39
+ export { AnalyticsIcon };
@@ -1,5 +1,6 @@
1
1
  export * from './activity-icon.js';
2
2
  export * from './ai-icon.js';
3
+ export * from './analytics-icon.js';
3
4
  export * from './byline-icon.js';
4
5
  export * from './calendar-icon.js';
5
6
  export * from './check-icon.js';
@@ -1,5 +1,6 @@
1
1
  export * from "./activity-icon.js";
2
2
  export * from "./ai-icon.js";
3
+ export * from "./analytics-icon.js";
3
4
  export * from "./byline-icon.js";
4
5
  export * from "./calendar-icon.js";
5
6
  export * from "./check-icon.js";
package/dist/uikit.d.ts CHANGED
@@ -46,6 +46,7 @@ export * from './components/tabs/tabs.js';
46
46
  export * from './components/tooltip/tooltip.js';
47
47
  export * from './icons/activity-icon.js';
48
48
  export * from './icons/ai-icon.js';
49
+ export * from './icons/analytics-icon.js';
49
50
  export * from './icons/byline-icon.js';
50
51
  export * from './icons/calendar-icon.js';
51
52
  export * from './icons/check-icon.js';
package/dist/uikit.js CHANGED
@@ -41,6 +41,7 @@ export * from "./components/tabs/tabs.js";
41
41
  export * from "./components/tooltip/tooltip.js";
42
42
  export * from "./icons/activity-icon.js";
43
43
  export * from "./icons/ai-icon.js";
44
+ export * from "./icons/analytics-icon.js";
44
45
  export * from "./icons/byline-icon.js";
45
46
  export * from "./icons/calendar-icon.js";
46
47
  export * from "./icons/check-icon.js";
@@ -6,6 +6,23 @@
6
6
  */
7
7
  import type React from 'react';
8
8
  import type { Intent, Size, Variant } from '../../components/inputs/@types/input.js';
9
+ /**
10
+ * The selection as the editor made it: a calendar day and a clock reading,
11
+ * with no instant attached.
12
+ *
13
+ * `onDateChange` reports a `Date`, which is an instant, and building one from a
14
+ * wall time goes through `setHours` — so on the two days a year the clocks
15
+ * change, the instant is not the time that was picked. A spring-forward 02:30
16
+ * comes back as 03:30, and an ambiguous autumn 01:30 silently resolves to the
17
+ * earlier of its two instants. Callers that must reject or disambiguate those
18
+ * cases need the wall time itself, which is what this carries.
19
+ */
20
+ export interface DatePickerWallTime {
21
+ /** Calendar day as `YYYY-MM-DD`. */
22
+ date: string;
23
+ /** Clock reading as `HH:mm`, 24-hour. */
24
+ time: string;
25
+ }
9
26
  export interface DatePickerProps extends React.InputHTMLAttributes<HTMLInputElement> {
10
27
  id: string;
11
28
  name: string;
@@ -13,6 +30,23 @@ export interface DatePickerProps extends React.InputHTMLAttributes<HTMLInputElem
13
30
  required?: boolean;
14
31
  initialValue?: Date | null;
15
32
  mode?: 'date' | 'datetime';
33
+ /**
34
+ * Earliest selectable day. Days before it are disabled in the calendar and
35
+ * the month navigation will not go back past its month.
36
+ *
37
+ * Day granularity only — the day containing `minDate` stays selectable in
38
+ * full, and in `datetime` mode every slot in the time list remains offered.
39
+ * A caller that needs a cutoff finer than a day has to enforce it itself.
40
+ */
41
+ minDate?: Date;
42
+ /**
43
+ * Latest selectable day, the mirror of `minDate`: days after it are disabled
44
+ * and the month navigation will not go forward past its month. Takes
45
+ * precedence over `yearsInFuture`, which otherwise sets the upper bound.
46
+ *
47
+ * Day granularity only, on the same terms as `minDate`.
48
+ */
49
+ maxDate?: Date;
16
50
  yearsInFuture?: number;
17
51
  yearsInPast?: number;
18
52
  variant?: Variant;
@@ -28,10 +62,17 @@ export interface DatePickerProps extends React.InputHTMLAttributes<HTMLInputElem
28
62
  ariaLabelForClear?: string;
29
63
  onClear?: () => void;
30
64
  onDateChange?: (value: Date | null) => void;
65
+ /**
66
+ * Fired alongside `onDateChange` with the day and clock reading the editor
67
+ * actually selected, before any instant is derived from them. Optional and
68
+ * additive — callers that are happy with the `Date` can ignore it entirely.
69
+ * Reports `null` when the selection is cleared.
70
+ */
71
+ onWallTimeChange?: (wall: DatePickerWallTime | null) => void;
31
72
  validatorFn?: (value: Date) => {
32
73
  valid: boolean;
33
74
  value: Date;
34
75
  };
35
76
  placeHolderText?: string;
36
77
  }
37
- export declare function DatePicker({ id, name, label, required, initialValue, mode, yearsInFuture, yearsInPast, variant, intent, inputSize, inputClassName, inputWrapperClassName, containerClassName, contentClassName, onClear, onDateChange, validatorFn, helpText, errorText, placeHolderText, ariaLabelForSearch, ariaLabelForClear, ...rest }: DatePickerProps): React.JSX.Element;
78
+ export declare function DatePicker({ id, name, label, required, initialValue, mode, minDate, maxDate, yearsInFuture, yearsInPast, variant, intent, inputSize, inputClassName, inputWrapperClassName, containerClassName, contentClassName, onClear, onDateChange, onWallTimeChange, validatorFn, helpText, errorText, placeHolderText, ariaLabelForSearch, ariaLabelForClear, ...rest }: DatePickerProps): React.JSX.Element;
@@ -13,14 +13,14 @@ import { ScrollArea } from "../../components/scroll-area/scroll-area.js";
13
13
  import { CalendarIcon } from "../../icons/calendar-icon.js";
14
14
  import { CloseIcon } from "../../icons/close-icon.js";
15
15
  import datepicker_module from "./datepicker.module.js";
16
- function DatePicker({ id, name, label, required, initialValue, mode = 'datetime', yearsInFuture = 1, yearsInPast = 10, variant, intent, inputSize, inputClassName, inputWrapperClassName, containerClassName, contentClassName, onClear = ()=>{}, onDateChange = ()=>{}, validatorFn, helpText, errorText, placeHolderText = '', ariaLabelForSearch = 'date', ariaLabelForClear = 'clear', ...rest }) {
16
+ function DatePicker({ id, name, label, required, initialValue, mode = 'datetime', minDate, maxDate, yearsInFuture = 1, yearsInPast = 10, variant, intent, inputSize, inputClassName, inputWrapperClassName, containerClassName, contentClassName, onClear = ()=>{}, onDateChange = ()=>{}, onWallTimeChange, validatorFn, helpText, errorText, placeHolderText = '', ariaLabelForSearch = 'date', ariaLabelForClear = 'clear', ...rest }) {
17
17
  const [isOpen, setIsOpen] = useState(false);
18
- const [time, setTime] = useState('08:00');
19
18
  const [date, setDate] = useState(()=>{
20
19
  if (initialValue) return initialValue;
21
20
  if (null == initialValue && true === required) return new Date();
22
21
  return null;
23
22
  });
23
+ const [time, setTime] = useState(()=>null != date ? format(date, 'HH:mm') : '08:00');
24
24
  const [month, setMonth] = useState(date);
25
25
  const calendarRef = useRef(null);
26
26
  const inputRef = useRef(null);
@@ -29,11 +29,31 @@ function DatePicker({ id, name, label, required, initialValue, mode = 'datetime'
29
29
  if (inputRef?.current != null) inputRef.current.value = '';
30
30
  setDate(null);
31
31
  onDateChange(null);
32
+ emitWallTime(null, time);
32
33
  onClear();
33
34
  };
34
35
  const handleOnDateChange = (value)=>{
35
36
  if (null != onDateChange && 'function' == typeof onDateChange) onDateChange(value);
36
37
  };
38
+ const emitWallTime = (day, clock)=>{
39
+ if (null == onWallTimeChange) return;
40
+ onWallTimeChange(null == day ? null : {
41
+ date: format(day, 'yyyy-MM-dd'),
42
+ time: clock
43
+ });
44
+ };
45
+ const disabledDays = [
46
+ ...null == minDate ? [] : [
47
+ {
48
+ before: minDate
49
+ }
50
+ ],
51
+ ...null == maxDate ? [] : [
52
+ {
53
+ after: maxDate
54
+ }
55
+ ]
56
+ ];
37
57
  const handleOnKeyDown = (e)=>{
38
58
  if ('ArrowDown' === e.key) {
39
59
  e.preventDefault();
@@ -44,6 +64,7 @@ function DatePicker({ id, name, label, required, initialValue, mode = 'datetime'
44
64
  if (null == initialValue && null != date && true === required && false === hasInitialized.current) {
45
65
  hasInitialized.current = true;
46
66
  onDateChange(date);
67
+ emitWallTime(date, time);
47
68
  }
48
69
  });
49
70
  return /*#__PURE__*/ jsxs("div", {
@@ -134,6 +155,7 @@ function DatePicker({ id, name, label, required, initialValue, mode = 'datetime'
134
155
  /*#__PURE__*/ jsx(Popover.Portal, {
135
156
  children: /*#__PURE__*/ jsx(Popover.Positioner, {
136
157
  sideOffset: 5,
158
+ className: clsx('byline-datepicker-positioner', datepicker_module.positioner),
137
159
  children: /*#__PURE__*/ jsxs(Popover.Popup, {
138
160
  className: clsx('byline-datepicker-content', datepicker_module.content, contentClassName),
139
161
  children: [
@@ -151,15 +173,18 @@ function DatePicker({ id, name, label, required, initialValue, mode = 'datetime'
151
173
  onMonthChange: setMonth,
152
174
  onSelect: (selectedDate)=>{
153
175
  if (selectedDate) {
176
+ const day = new Date(selectedDate.getTime());
154
177
  const [hours, minutes] = time.split(':');
155
178
  selectedDate.setHours(Number.parseInt(hours, 10), Number.parseInt(minutes, 10));
156
179
  setDate(selectedDate);
157
180
  setMonth(selectedDate);
158
181
  handleOnDateChange(selectedDate);
182
+ emitWallTime(day, time);
159
183
  }
160
184
  },
161
- startMonth: new Date(new Date().getFullYear() - yearsInPast, 0),
162
- endMonth: new Date(new Date().getFullYear() + yearsInFuture, 0)
185
+ disabled: disabledDays.length > 0 ? disabledDays : void 0,
186
+ startMonth: minDate ?? new Date(new Date().getFullYear() - yearsInPast, 0),
187
+ endMonth: maxDate ?? new Date(new Date().getFullYear() + yearsInFuture, 0)
163
188
  })
164
189
  }),
165
190
  'datetime' === mode && /*#__PURE__*/ jsx("div", {
@@ -185,6 +210,7 @@ function DatePicker({ id, name, label, required, initialValue, mode = 'datetime'
185
210
  newDate.setHours(Number.parseInt(hour, 10), Number.parseInt(minute, 10), 0);
186
211
  setDate(newDate);
187
212
  handleOnDateChange(newDate);
213
+ emitWallTime(date, timeValue);
188
214
  }
189
215
  },
190
216
  children: timeValue
@@ -212,9 +238,12 @@ function DatePicker({ id, name, label, required, initialValue, mode = 'datetime'
212
238
  className: clsx('byline-datepicker-content-actions-button', datepicker_module["content-actions-button"]),
213
239
  onClick: ()=>{
214
240
  const today = new Date();
241
+ const clock = format(today, 'HH:mm');
215
242
  setDate(today);
216
243
  setMonth(today);
244
+ setTime(clock);
217
245
  handleOnDateChange(today);
246
+ emitWallTime(today, clock);
218
247
  },
219
248
  children: "Today"
220
249
  })
@@ -241,6 +270,7 @@ function DatePicker({ id, name, label, required, initialValue, mode = 'datetime'
241
270
  onClick: ()=>{
242
271
  setIsOpen(false);
243
272
  handleOnDateChange(date);
273
+ emitWallTime(date, time);
244
274
  },
245
275
  children: "Select"
246
276
  })
@@ -4,6 +4,7 @@ const datepicker_module = {
4
4
  input: "input-SaODq9",
5
5
  "input-wrapper": "input-wrapper-qwAckv",
6
6
  inputWrapper: "input-wrapper-qwAckv",
7
+ positioner: "positioner-l698qq",
7
8
  content: "content-UK9iLF",
8
9
  slideDownAndFade: "slideDownAndFade-dohHTE",
9
10
  slideLeftAndFade: "slideLeftAndFade-ugM6Hs",
@@ -9,9 +9,12 @@
9
9
  width: 100%;
10
10
  }
11
11
 
12
+ :is(.positioner-l698qq, .byline-datepicker-positioner) {
13
+ z-index: var(--z-index-popover);
14
+ }
15
+
12
16
  :is(.content-UK9iLF, .byline-datepicker-content) {
13
17
  width: 100%;
14
- z-index: var(--z-index-popover);
15
18
  padding-top: var(--spacing-16);
16
19
  padding-bottom: var(--spacing-8);
17
20
  padding-left: var(--spacing-8);
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Shared between `Modal` and its slots. Lives in its own module because
3
+ * `Modal` renders `Modal.Header` while `Modal.Header` needs the context —
4
+ * importing it from `modal.tsx` would make the two modules circular.
5
+ */
6
+ export declare const ModalContext: import("react").Context<{
7
+ onDismiss?: () => void;
8
+ /**
9
+ * Id the dialog points `aria-labelledby` at. `Modal.Header` puts it on the
10
+ * heading it finds among its children, which is what gives the dialog its
11
+ * accessible name — without it a screen reader announces every modal in the
12
+ * admin as an unnamed "dialog".
13
+ *
14
+ * A dangling reference degrades safely: name computation ignores an
15
+ * `aria-labelledby` that resolves to nothing and falls through, so a modal
16
+ * with no header is no worse off than before.
17
+ */
18
+ headingId?: string;
19
+ }>;
@@ -0,0 +1,4 @@
1
+ "use client";
2
+ import { createContext } from "react";
3
+ const ModalContext = createContext({});
4
+ export { ModalContext };
@@ -1,4 +1,4 @@
1
- import type * as React from 'react';
1
+ import * as React from 'react';
2
2
  type ModalHeaderIntrinsicProps = React.JSX.IntrinsicElements['div'];
3
3
  export interface ModalHeaderProps extends ModalHeaderIntrinsicProps {
4
4
  className?: string;
@@ -2,7 +2,29 @@
2
2
  import { jsx } from "react/jsx-runtime";
3
3
  import clsx from "clsx";
4
4
  import modal_module from "./modal.module.js";
5
+ import { ModalContext } from "./modal-context.js";
6
+ import * as __rspack_external_react from "react";
7
+ const HEADING_TAGS = new Set([
8
+ 'h1',
9
+ 'h2',
10
+ 'h3',
11
+ 'h4',
12
+ 'h5',
13
+ 'h6'
14
+ ]);
15
+ function labelHeading(children, headingId) {
16
+ let labelled = false;
17
+ return __rspack_external_react.Children.map(children, (child)=>{
18
+ if (labelled || !/*#__PURE__*/ __rspack_external_react.isValidElement(child)) return child;
19
+ if ('string' != typeof child.type || !HEADING_TAGS.has(child.type)) return child;
20
+ labelled = true;
21
+ return /*#__PURE__*/ __rspack_external_react.cloneElement(child, {
22
+ id: headingId
23
+ });
24
+ });
25
+ }
5
26
  const modal_header_ModalHeader = function({ ref, children, className, ...rest }) {
27
+ const { headingId } = (0, __rspack_external_react.useContext)(ModalContext);
6
28
  const classes = clsx('byline-modal-header', modal_module["modal-header"], 'prose', className);
7
29
  return /*#__PURE__*/ jsx("div", {
8
30
  style: {
@@ -11,7 +33,7 @@ const modal_header_ModalHeader = function({ ref, children, className, ...rest })
11
33
  ref: ref,
12
34
  ...rest,
13
35
  className: classes,
14
- children: children
36
+ children: null == headingId ? children : labelHeading(children, headingId)
15
37
  });
16
38
  };
17
39
  export { modal_header_ModalHeader as ModalHeader };
@@ -2,16 +2,21 @@ import type React from 'react';
2
2
  import { ModalActions } from './modal-actions';
3
3
  import { ModalContainer } from './modal-container';
4
4
  import { ModalContent } from './modal-content';
5
+ import { ModalContext } from './modal-context.js';
5
6
  import { ModalHeader } from './modal-header';
7
+ export { ModalContext };
6
8
  export interface ModalProps {
7
9
  isOpen?: boolean;
8
10
  onDismiss?: () => void;
9
11
  closeOnOverlayClick?: boolean;
12
+ /**
13
+ * Accessible name for the dialog, for modals that carry no `Modal.Header`
14
+ * heading to borrow one from. Modals that do have a heading need nothing
15
+ * here — see `ModalContext.headingId`.
16
+ */
17
+ ariaLabel?: string;
10
18
  children?: React.ReactNode;
11
19
  }
12
- export declare const ModalContext: React.Context<{
13
- onDismiss?: () => void;
14
- }>;
15
20
  export type UseModalProps = ReturnType<typeof useModal>;
16
21
  export declare function useModal(): {
17
22
  onDismiss: () => void;
@@ -19,7 +24,7 @@ export declare function useModal(): {
19
24
  isOpen: boolean;
20
25
  setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
21
26
  };
22
- declare function Modal({ isOpen, onDismiss, closeOnOverlayClick, children, }: ModalProps): React.JSX.Element;
27
+ declare function Modal({ isOpen, onDismiss, closeOnOverlayClick, ariaLabel, children, }: ModalProps): React.JSX.Element;
23
28
  declare namespace Modal {
24
29
  var displayName: string;
25
30
  export { ModalContainer as Container };
@@ -1,14 +1,14 @@
1
1
  "use client";
2
2
  import { jsx, jsxs } from "react/jsx-runtime";
3
- import { createContext, useCallback, useRef, useState } from "react";
3
+ import { useCallback, useId, useRef, useState } from "react";
4
4
  import { Dialog } from "@base-ui/react/dialog";
5
5
  import clsx from "clsx";
6
6
  import modal_module from "./modal.module.js";
7
7
  import { ModalActions } from "./modal-actions.js";
8
8
  import { ModalContainer } from "./modal-container.js";
9
9
  import { ModalContent } from "./modal-content.js";
10
+ import { ModalContext } from "./modal-context.js";
10
11
  import { ModalHeader } from "./modal-header.js";
11
- const ModalContext = /*#__PURE__*/ createContext({});
12
12
  function useModal() {
13
13
  const [isOpen, setIsOpen] = useState(false);
14
14
  const onDismiss = useCallback(()=>{
@@ -24,7 +24,8 @@ function useModal() {
24
24
  setIsOpen
25
25
  };
26
26
  }
27
- function Modal({ isOpen, onDismiss, closeOnOverlayClick, children }) {
27
+ function Modal({ isOpen, onDismiss, closeOnOverlayClick, ariaLabel, children }) {
28
+ const headingId = useId();
28
29
  const pressStartedOnOverlay = useRef(false);
29
30
  const handleOverlayPointerDown = (event)=>{
30
31
  pressStartedOnOverlay.current = event.target === event.currentTarget;
@@ -38,7 +39,8 @@ function Modal({ isOpen, onDismiss, closeOnOverlayClick, children }) {
38
39
  };
39
40
  return /*#__PURE__*/ jsx(ModalContext.Provider, {
40
41
  value: {
41
- onDismiss
42
+ onDismiss,
43
+ headingId
42
44
  },
43
45
  children: /*#__PURE__*/ jsx(Dialog.Root, {
44
46
  open: isOpen,
@@ -54,6 +56,8 @@ function Modal({ isOpen, onDismiss, closeOnOverlayClick, children }) {
54
56
  }),
55
57
  /*#__PURE__*/ jsx(Dialog.Popup, {
56
58
  className: clsx('byline-modal-wrapper', modal_module["modal-wrapper"]),
59
+ "aria-label": ariaLabel,
60
+ "aria-labelledby": null == ariaLabel ? headingId : void 0,
57
61
  onPointerDown: handleOverlayPointerDown,
58
62
  onClick: handleOverlayClick,
59
63
  children: children
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "private": false,
4
4
  "type": "module",
5
5
  "license": "MPL-2.0",
6
- "version": "4.14.1",
6
+ "version": "4.16.0",
7
7
  "engines": {
8
8
  "node": ">=20.9.0"
9
9
  },
@@ -0,0 +1,48 @@
1
+ import type React from 'react'
2
+
3
+ import cx from 'clsx'
4
+
5
+ import { IconElement } from './icon-element.js'
6
+ import styles from './icons.module.css'
7
+ import type { IconProps } from './types/icon.js'
8
+
9
+ /**
10
+ * Columns standing on an L-shaped axis. After lucide `chart-column-increasing`
11
+ * (ISC), redrawn to this set's 1.75 stroke; source kept in
12
+ * `source/icon-analytics.svg`. The column feet terminate one unit above the
13
+ * axis so their round caps meet it rather than crossing it.
14
+ *
15
+ * Distinct from `DashboardIcon`, which is a four-panel layout grid and reads as
16
+ * "dashboard", not "measurement".
17
+ */
18
+ export const AnalyticsIcon = ({
19
+ className,
20
+ svgClassName,
21
+ ...rest
22
+ }: IconProps): React.JSX.Element => {
23
+ const applied = cx(styles['fill-none'], styles['stroke-current'], svgClassName)
24
+
25
+ return (
26
+ <IconElement className={cx('analytics-icon', className)} {...rest}>
27
+ <svg
28
+ className={applied}
29
+ xmlns="http://www.w3.org/2000/svg"
30
+ focusable="false"
31
+ aria-hidden="true"
32
+ viewBox="0 0 24 24"
33
+ fill="none"
34
+ stroke="currentColor"
35
+ strokeLinecap="round"
36
+ strokeLinejoin="round"
37
+ strokeWidth={1.75}
38
+ >
39
+ <path d="M3 3v16a2 2 0 0 0 2 2h16" />
40
+ <path d="M8 20v-4" />
41
+ <path d="M13 20v-9" />
42
+ <path d="M18 20v-13" />
43
+ </svg>
44
+ </IconElement>
45
+ )
46
+ }
47
+
48
+ AnalyticsIcon.displayName = 'AnalyticsIcon'
@@ -1,5 +1,6 @@
1
1
  export * from './activity-icon.js'
2
2
  export * from './ai-icon.js'
3
+ export * from './analytics-icon.js'
3
4
  export * from './byline-icon.js'
4
5
  export * from './calendar-icon.js'
5
6
  export * from './check-icon.js'
@@ -0,0 +1,2 @@
1
+ <!-- After lucide `chart-column-increasing` (ISC), redrawn for analytics-icon.tsx. -->
2
+ <svg role="presentation" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3v16a2 2 0 0 0 2 2h16"/><path d="M8 20v-4"/><path d="M13 20v-9"/><path d="M18 20v-13"/></svg>
package/src/uikit.ts CHANGED
@@ -55,6 +55,7 @@ export * from './components/tabs/tabs.js'
55
55
  export * from './components/tooltip/tooltip.js'
56
56
  export * from './icons/activity-icon.js'
57
57
  export * from './icons/ai-icon.js'
58
+ export * from './icons/analytics-icon.js'
58
59
  export * from './icons/byline-icon.js'
59
60
  export * from './icons/calendar-icon.js'
60
61
  export * from './icons/check-icon.js'
@@ -21,10 +21,19 @@
21
21
  width: 100%;
22
22
  }
23
23
 
24
+ /* Base UI's Positioner is the absolutely-positioned element, so it is the
25
+ one that can take part in z-index stacking. The Popup inside it is
26
+ `position: static`, where a z-index is inert — which is why the calendar
27
+ lost to any positioned ancestor above it in the tree, most visibly by
28
+ rendering behind a Modal. */
29
+ .positioner,
30
+ :global(.byline-datepicker-positioner) {
31
+ z-index: var(--z-index-popover);
32
+ }
33
+
24
34
  .content,
25
35
  :global(.byline-datepicker-content) {
26
36
  width: 100%;
27
- z-index: var(--z-index-popover);
28
37
  border-radius: 4px;
29
38
  padding-top: var(--spacing-16);
30
39
  padding-bottom: var(--spacing-8);
@@ -25,6 +25,24 @@ import { CloseIcon } from '../../icons/close-icon.js'
25
25
  import styles from './datepicker.module.css'
26
26
  import type { Intent, Size, Variant } from '../../components/inputs/@types/input.js'
27
27
 
28
+ /**
29
+ * The selection as the editor made it: a calendar day and a clock reading,
30
+ * with no instant attached.
31
+ *
32
+ * `onDateChange` reports a `Date`, which is an instant, and building one from a
33
+ * wall time goes through `setHours` — so on the two days a year the clocks
34
+ * change, the instant is not the time that was picked. A spring-forward 02:30
35
+ * comes back as 03:30, and an ambiguous autumn 01:30 silently resolves to the
36
+ * earlier of its two instants. Callers that must reject or disambiguate those
37
+ * cases need the wall time itself, which is what this carries.
38
+ */
39
+ export interface DatePickerWallTime {
40
+ /** Calendar day as `YYYY-MM-DD`. */
41
+ date: string
42
+ /** Clock reading as `HH:mm`, 24-hour. */
43
+ time: string
44
+ }
45
+
28
46
  export interface DatePickerProps extends React.InputHTMLAttributes<HTMLInputElement> {
29
47
  id: string
30
48
  name: string
@@ -32,6 +50,23 @@ export interface DatePickerProps extends React.InputHTMLAttributes<HTMLInputElem
32
50
  required?: boolean
33
51
  initialValue?: Date | null
34
52
  mode?: 'date' | 'datetime'
53
+ /**
54
+ * Earliest selectable day. Days before it are disabled in the calendar and
55
+ * the month navigation will not go back past its month.
56
+ *
57
+ * Day granularity only — the day containing `minDate` stays selectable in
58
+ * full, and in `datetime` mode every slot in the time list remains offered.
59
+ * A caller that needs a cutoff finer than a day has to enforce it itself.
60
+ */
61
+ minDate?: Date
62
+ /**
63
+ * Latest selectable day, the mirror of `minDate`: days after it are disabled
64
+ * and the month navigation will not go forward past its month. Takes
65
+ * precedence over `yearsInFuture`, which otherwise sets the upper bound.
66
+ *
67
+ * Day granularity only, on the same terms as `minDate`.
68
+ */
69
+ maxDate?: Date
35
70
  yearsInFuture?: number
36
71
  yearsInPast?: number
37
72
  variant?: Variant
@@ -47,6 +82,13 @@ export interface DatePickerProps extends React.InputHTMLAttributes<HTMLInputElem
47
82
  ariaLabelForClear?: string
48
83
  onClear?: () => void
49
84
  onDateChange?: (value: Date | null) => void
85
+ /**
86
+ * Fired alongside `onDateChange` with the day and clock reading the editor
87
+ * actually selected, before any instant is derived from them. Optional and
88
+ * additive — callers that are happy with the `Date` can ignore it entirely.
89
+ * Reports `null` when the selection is cleared.
90
+ */
91
+ onWallTimeChange?: (wall: DatePickerWallTime | null) => void
50
92
  validatorFn?: (value: Date) => {
51
93
  valid: boolean
52
94
  value: Date
@@ -61,6 +103,8 @@ export function DatePicker({
61
103
  required,
62
104
  initialValue,
63
105
  mode = 'datetime',
106
+ minDate,
107
+ maxDate,
64
108
  yearsInFuture = 1,
65
109
  yearsInPast = 10,
66
110
  variant,
@@ -72,6 +116,7 @@ export function DatePicker({
72
116
  contentClassName,
73
117
  onClear = () => {},
74
118
  onDateChange = () => {},
119
+ onWallTimeChange,
75
120
  validatorFn,
76
121
  helpText,
77
122
  errorText,
@@ -81,7 +126,6 @@ export function DatePicker({
81
126
  ...rest
82
127
  }: DatePickerProps): React.JSX.Element {
83
128
  const [isOpen, setIsOpen] = useState(false)
84
- const [time, setTime] = useState<string>('08:00')
85
129
  const [date, setDate] = useState<Date | null>(() => {
86
130
  if (initialValue) {
87
131
  return initialValue
@@ -91,6 +135,11 @@ export function DatePicker({
91
135
  }
92
136
  return null
93
137
  })
138
+ // Seeded from the incoming date so the clock the widget holds agrees with the
139
+ // date it is showing. Declared after `date` for that reason. Previously this
140
+ // always started at 08:00, so picking a different day on a picker opened at
141
+ // 17:56 silently moved the value to 08:00.
142
+ const [time, setTime] = useState<string>(() => (date != null ? format(date, 'HH:mm') : '08:00'))
94
143
  const [month, setMonth] = useState<Date | null>(date)
95
144
  const calendarRef = useRef<HTMLDivElement | null>(null)
96
145
  const inputRef = useRef<HTMLInputElement>(null)
@@ -102,6 +151,7 @@ export function DatePicker({
102
151
  }
103
152
  setDate(null)
104
153
  onDateChange(null)
154
+ emitWallTime(null, time)
105
155
  onClear()
106
156
  }
107
157
 
@@ -111,6 +161,26 @@ export function DatePicker({
111
161
  }
112
162
  }
113
163
 
164
+ /**
165
+ * Report the selection as a wall time. `clock` is passed explicitly wherever
166
+ * the editor's chosen clock reading is known independently of `day`, because
167
+ * `day` has already been through `setHours` by then and cannot be trusted to
168
+ * still say what was picked.
169
+ */
170
+ const emitWallTime = (day: Date | null, clock: string): void => {
171
+ if (onWallTimeChange == null) return
172
+ onWallTimeChange(day == null ? null : { date: format(day, 'yyyy-MM-dd'), time: clock })
173
+ }
174
+
175
+ // Two separate matchers, never one `{ before, after }` object: that shape is
176
+ // react-day-picker's DateInterval and matches the days *between* the two
177
+ // bounds — the exact inverse of an allowed window. An array of matchers is
178
+ // OR'd, so each bound disables its own side.
179
+ const disabledDays = [
180
+ ...(minDate == null ? [] : [{ before: minDate }]),
181
+ ...(maxDate == null ? [] : [{ after: maxDate }]),
182
+ ]
183
+
114
184
  const handleOnKeyDown = (e: React.KeyboardEvent<HTMLInputElement>): void => {
115
185
  if (e.key === 'ArrowDown') {
116
186
  e.preventDefault()
@@ -132,6 +202,7 @@ export function DatePicker({
132
202
  ) {
133
203
  hasInitialized.current = true
134
204
  onDateChange(date)
205
+ emitWallTime(date, time)
135
206
  }
136
207
  })
137
208
 
@@ -205,7 +276,10 @@ export function DatePicker({
205
276
  <span className="sr-only">Select date</span>
206
277
  </Popover.Trigger>
207
278
  <Popover.Portal>
208
- <Popover.Positioner sideOffset={5}>
279
+ <Popover.Positioner
280
+ sideOffset={5}
281
+ className={cx('byline-datepicker-positioner', styles.positioner)}
282
+ >
209
283
  <Popover.Popup
210
284
  className={cx('byline-datepicker-content', styles.content, contentClassName)}
211
285
  >
@@ -222,6 +296,10 @@ export function DatePicker({
222
296
  onMonthChange={setMonth}
223
297
  onSelect={(selectedDate: Date) => {
224
298
  if (selectedDate) {
299
+ // Read the day before `setHours` mutates it: the
300
+ // editor picked this day at the clock reading already
301
+ // held in `time`, and normalization can move both.
302
+ const day = new Date(selectedDate.getTime())
225
303
  const [hours, minutes] = time.split(':')
226
304
  selectedDate.setHours(
227
305
  Number.parseInt(hours, 10),
@@ -230,15 +308,15 @@ export function DatePicker({
230
308
  setDate(selectedDate)
231
309
  setMonth(selectedDate)
232
310
  handleOnDateChange(selectedDate)
311
+ emitWallTime(day, time)
233
312
  }
234
313
  }}
235
- startMonth={new Date(new Date().getFullYear() - yearsInPast, 0)}
236
- endMonth={new Date(new Date().getFullYear() + yearsInFuture, 0)}
237
- // TODO: add props
238
- // disabled={(date) =>
239
- // Number(date) < Date.now() - 1000 * 60 * 60 * 24 ||
240
- // Number(date) > Date.now() + 1000 * 60 * 60 * 24 * 30
241
- // }
314
+ disabled={disabledDays.length > 0 ? disabledDays : undefined}
315
+ // Clamp the navigable range to the bounds as well as
316
+ // disabling the days, so the caller is not offered months
317
+ // in which nothing can be picked.
318
+ startMonth={minDate ?? new Date(new Date().getFullYear() - yearsInPast, 0)}
319
+ endMonth={maxDate ?? new Date(new Date().getFullYear() + yearsInFuture, 0)}
242
320
  />
243
321
  </div>
244
322
  {mode === 'datetime' && (
@@ -268,6 +346,10 @@ export function DatePicker({
268
346
  )
269
347
  setDate(newDate)
270
348
  handleOnDateChange(newDate)
349
+ // `date`, not `newDate` — the grid label is
350
+ // what was clicked, and `newDate` may have
351
+ // been normalized away from it.
352
+ emitWallTime(date, timeValue)
271
353
  }
272
354
  }}
273
355
  >
@@ -297,9 +379,14 @@ export function DatePicker({
297
379
  )}
298
380
  onClick={() => {
299
381
  const today = new Date()
382
+ const clock = format(today, 'HH:mm')
300
383
  setDate(today)
301
384
  setMonth(today)
385
+ // Keep the held clock in step with the value, so a
386
+ // subsequent day pick preserves this time.
387
+ setTime(clock)
302
388
  handleOnDateChange(today)
389
+ emitWallTime(today, clock)
303
390
  }}
304
391
  >
305
392
  Today
@@ -329,6 +416,7 @@ export function DatePicker({
329
416
  onClick={() => {
330
417
  setIsOpen(false)
331
418
  handleOnDateChange(date)
419
+ emitWallTime(date, time)
332
420
  }}
333
421
  >
334
422
  Select
@@ -0,0 +1,23 @@
1
+ 'use client'
2
+
3
+ import { createContext } from 'react'
4
+
5
+ /**
6
+ * Shared between `Modal` and its slots. Lives in its own module because
7
+ * `Modal` renders `Modal.Header` while `Modal.Header` needs the context —
8
+ * importing it from `modal.tsx` would make the two modules circular.
9
+ */
10
+ export const ModalContext = createContext<{
11
+ onDismiss?: () => void
12
+ /**
13
+ * Id the dialog points `aria-labelledby` at. `Modal.Header` puts it on the
14
+ * heading it finds among its children, which is what gives the dialog its
15
+ * accessible name — without it a screen reader announces every modal in the
16
+ * admin as an unnamed "dialog".
17
+ *
18
+ * A dangling reference degrades safely: name computation ignores an
19
+ * `aria-labelledby` that resolves to nothing and falls through, so a modal
20
+ * with no header is no worse off than before.
21
+ */
22
+ headingId?: string
23
+ }>({})
@@ -1,16 +1,42 @@
1
1
  'use client'
2
2
 
3
- import type * as React from 'react'
3
+ import * as React from 'react'
4
+ import { useContext } from 'react'
4
5
 
5
6
  import cx from 'clsx'
6
7
 
7
8
  import styles from './modal.module.css'
9
+ import { ModalContext } from './modal-context.js'
8
10
 
9
11
  type ModalHeaderIntrinsicProps = React.JSX.IntrinsicElements['div']
10
12
  export interface ModalHeaderProps extends ModalHeaderIntrinsicProps {
11
13
  className?: string
12
14
  }
13
15
 
16
+ const HEADING_TAGS = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6'])
17
+
18
+ /**
19
+ * Give the dialog's heading the id that `Modal` points `aria-labelledby` at.
20
+ *
21
+ * The heading is found rather than declared, so no caller has to change: every
22
+ * header in the admin is written as a heading element optionally followed by a
23
+ * close button. Only the first heading is labelled, and only direct children
24
+ * are searched — a header deep enough to hide its heading is better served by
25
+ * `Modal`'s explicit `ariaLabel`.
26
+ *
27
+ * `Modal.Header` owns the heading's `id` for this reason; an id set by a caller
28
+ * is replaced, because the dialog's name depends on this one resolving.
29
+ */
30
+ function labelHeading(children: React.ReactNode, headingId: string): React.ReactNode {
31
+ let labelled = false
32
+ return React.Children.map(children, (child) => {
33
+ if (labelled || !React.isValidElement(child)) return child
34
+ if (typeof child.type !== 'string' || !HEADING_TAGS.has(child.type)) return child
35
+ labelled = true
36
+ return React.cloneElement(child as React.ReactElement<{ id?: string }>, { id: headingId })
37
+ })
38
+ }
39
+
14
40
  export const ModalHeader = function ModalHeader({
15
41
  ref,
16
42
  children,
@@ -19,10 +45,11 @@ export const ModalHeader = function ModalHeader({
19
45
  }: ModalHeaderProps & {
20
46
  ref?: React.RefObject<HTMLDivElement>
21
47
  }) {
48
+ const { headingId } = useContext(ModalContext)
22
49
  const classes = cx('byline-modal-header', styles['modal-header'], 'prose', className)
23
50
  return (
24
51
  <div style={{ overflowWrap: 'anywhere' }} ref={ref} {...rest} className={classes}>
25
- {children}
52
+ {headingId == null ? children : labelHeading(children, headingId)}
26
53
  </div>
27
54
  )
28
55
  }
@@ -1,7 +1,7 @@
1
1
  'use client'
2
2
 
3
3
  import type React from 'react'
4
- import { createContext, useCallback, useRef, useState } from 'react'
4
+ import { useCallback, useId, useRef, useState } from 'react'
5
5
 
6
6
  import { Dialog } from '@base-ui/react/dialog'
7
7
  import cx from 'clsx'
@@ -10,19 +10,24 @@ import styles from './modal.module.css'
10
10
  import { ModalActions } from './modal-actions'
11
11
  import { ModalContainer } from './modal-container'
12
12
  import { ModalContent } from './modal-content'
13
+ import { ModalContext } from './modal-context.js'
13
14
  import { ModalHeader } from './modal-header'
14
15
 
16
+ export { ModalContext }
17
+
15
18
  export interface ModalProps {
16
19
  isOpen?: boolean
17
20
  onDismiss?: () => void
18
21
  closeOnOverlayClick?: boolean
22
+ /**
23
+ * Accessible name for the dialog, for modals that carry no `Modal.Header`
24
+ * heading to borrow one from. Modals that do have a heading need nothing
25
+ * here — see `ModalContext.headingId`.
26
+ */
27
+ ariaLabel?: string
19
28
  children?: React.ReactNode
20
29
  }
21
30
 
22
- export const ModalContext = createContext<{
23
- onDismiss?: () => void
24
- }>({})
25
-
26
31
  export type UseModalProps = ReturnType<typeof useModal>
27
32
 
28
33
  export function useModal(): {
@@ -53,8 +58,10 @@ function Modal({
53
58
  isOpen,
54
59
  onDismiss,
55
60
  closeOnOverlayClick,
61
+ ariaLabel,
56
62
  children,
57
63
  }: ModalProps): React.JSX.Element {
64
+ const headingId = useId()
58
65
  // Overlay dismissal is handled here rather than by Base UI's outside-press
59
66
  // detection. `Dialog.Popup` below is the full-viewport flex box that centres
60
67
  // the dialog, not the dialog box itself, so a click on the empty space around
@@ -83,7 +90,7 @@ function Modal({
83
90
  }
84
91
 
85
92
  return (
86
- <ModalContext.Provider value={{ onDismiss }}>
93
+ <ModalContext.Provider value={{ onDismiss, headingId }}>
87
94
  <Dialog.Root
88
95
  open={isOpen}
89
96
  onOpenChange={(open) => {
@@ -98,6 +105,8 @@ function Modal({
98
105
  <Dialog.Backdrop className={cx('byline-modal-backdrop', styles.backdrop)} />
99
106
  <Dialog.Popup
100
107
  className={cx('byline-modal-wrapper', styles['modal-wrapper'])}
108
+ aria-label={ariaLabel}
109
+ aria-labelledby={ariaLabel == null ? headingId : undefined}
101
110
  onPointerDown={handleOverlayPointerDown}
102
111
  onClick={handleOverlayClick}
103
112
  >