@surflane/ui 0.11.0 → 0.12.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.
Files changed (40) hide show
  1. package/dist/app-shell.d.ts +10 -0
  2. package/dist/app-shell.js +7 -1
  3. package/dist/bar-list.d.ts +31 -0
  4. package/dist/bar-list.js +25 -0
  5. package/dist/bottom-navigation.d.ts +6 -2
  6. package/dist/bottom-navigation.js +2 -2
  7. package/dist/chart.d.ts +56 -3
  8. package/dist/chart.js +266 -48
  9. package/dist/css/bar-list.css +9 -0
  10. package/dist/css/chart.css +2 -0
  11. package/dist/css/dashboard.css +20 -0
  12. package/dist/css/rules/alert-dialog.css +4 -0
  13. package/dist/css/rules/app-shell.css +10 -0
  14. package/dist/css/rules/bar-list.css +101 -0
  15. package/dist/css/rules/bottom-navigation.css +16 -0
  16. package/dist/css/rules/card.css +5 -0
  17. package/dist/css/rules/chart.css +74 -3
  18. package/dist/css/rules/dashboard.css +66 -0
  19. package/dist/css/rules/dialog.css +11 -0
  20. package/dist/css/rules/filter-bar.css +8 -1
  21. package/dist/css/rules/panel.css +9 -1
  22. package/dist/css/rules/select.css +25 -1
  23. package/dist/css/rules/sizing.css +10 -0
  24. package/dist/css/rules/stat.css +57 -0
  25. package/dist/css/rules/time-range-picker.css +14 -0
  26. package/dist/css/styles.css +3 -0
  27. package/dist/css/time-range-picker.css +14 -0
  28. package/dist/dashboard.d.ts +46 -0
  29. package/dist/dashboard.js +35 -0
  30. package/dist/icon.d.ts +10 -0
  31. package/dist/icon.js +11 -0
  32. package/dist/index.d.ts +6 -3
  33. package/dist/index.js +5 -2
  34. package/dist/select.d.ts +33 -6
  35. package/dist/select.js +94 -21
  36. package/dist/stat.d.ts +20 -0
  37. package/dist/stat.js +15 -1
  38. package/dist/time-range-picker.d.ts +42 -0
  39. package/dist/time-range-picker.js +69 -0
  40. package/package.json +18 -3
package/dist/select.d.ts CHANGED
@@ -10,11 +10,8 @@ export type SelectGroup = {
10
10
  label: React.ReactNode;
11
11
  options: SelectOption[];
12
12
  };
13
- export type SelectProps = {
13
+ type SelectCommonProps = {
14
14
  options: (SelectOption | SelectGroup)[];
15
- value?: string;
16
- defaultValue?: string;
17
- onValueChange?: (value: string) => void;
18
15
  name?: string;
19
16
  form?: string;
20
17
  required?: boolean;
@@ -24,12 +21,42 @@ export type SelectProps = {
24
21
  inputRef?: React.Ref<HTMLInputElement>;
25
22
  triggerRef?: React.Ref<HTMLButtonElement>;
26
23
  size?: ComponentSizeAlias;
24
+ /** fill takes its container's width, as a form field does. content fits the
25
+ * longest option, as a native select does, so it sits beside other controls in
26
+ * a header or toolbar and keeps its width when the value changes. */
27
+ width?: 'fill' | 'content';
27
28
  'aria-label'?: string;
28
29
  'aria-labelledby'?: string;
29
30
  'aria-describedby'?: string;
30
31
  'aria-invalid'?: React.AriaAttributes['aria-invalid'];
31
32
  };
32
- /** A single-choice list. The ref reaches the trigger button, as triggerRef does. */
33
- export declare const Select: ({ options: items, value, defaultValue, onValueChange, className, inputRef, triggerRef, size, ref, ...props }: SelectProps & {
33
+ export type SelectProps = SelectCommonProps & {
34
+ multiple?: false;
35
+ value?: string;
36
+ defaultValue?: string;
37
+ onValueChange?: (value: string) => void;
38
+ };
39
+ /** What a multiple Select's trigger says, and its first option. */
40
+ export type SelectMultipleLabels = {
41
+ /** Every option chosen, such as "All projects"; also the option that chooses them. */
42
+ all: string;
43
+ /** Nothing chosen, such as "No projects". */
44
+ none: string;
45
+ /** Several chosen, such as "3 projects". */
46
+ count: (count: number) => string;
47
+ };
48
+ /** Several choices from a list, summed up on one line: a filter of projects, models
49
+ * or nodes. Its first option, labels.all, chooses every option or none. */
50
+ export type SelectMultipleProps = SelectCommonProps & {
51
+ multiple: true;
52
+ value?: readonly string[];
53
+ defaultValue?: readonly string[];
54
+ onValueChange?: (value: string[]) => void;
55
+ labels?: Partial<SelectMultipleLabels>;
56
+ };
57
+ /** A choice from a list, or several with multiple. The ref reaches the trigger
58
+ * button, as triggerRef does. */
59
+ export declare const Select: (allProps: (SelectProps | SelectMultipleProps) & {
34
60
  ref?: React.Ref<HTMLButtonElement>;
35
61
  }) => React.JSX.Element;
62
+ export {};
package/dist/select.js CHANGED
@@ -1,5 +1,5 @@
1
1
  'use client';
2
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import * as React from 'react';
4
4
  import { Icon } from './icon.js';
5
5
  import { useComponentSize } from './sizing.js';
@@ -7,8 +7,17 @@ import { Select as BaseSelect } from '@base-ui/react/select';
7
7
  import { sizeName, cx } from './parts.js';
8
8
  const isGroup = (item) => 'options' in item;
9
9
  const flatten = (items) => items.flatMap((item) => (isGroup(item) ? item.options : [item]));
10
- /** A single-choice list. The ref reaches the trigger button, as triggerRef does. */
11
- export const Select = function Select({ options: items, value, defaultValue, onValueChange, className, inputRef, triggerRef, size, ref, ...props }) {
10
+ const multipleLabels = {
11
+ all: 'All',
12
+ none: 'None',
13
+ count: (count) => `${count} selected`,
14
+ };
15
+ // The value the "all" option carries; it never reaches a form or a callback.
16
+ const ALL = '\u0000all';
17
+ /** A choice from a list, or several with multiple. The ref reaches the trigger
18
+ * button, as triggerRef does. */
19
+ export const Select = function Select(allProps) {
20
+ const { options: items, className, inputRef, triggerRef, size, width = 'fill', ref, multiple, value: _value, defaultValue: _defaultValue, onValueChange: _onValueChange, labels: _labels, ...props } = allProps;
12
21
  const scheme = useComponentSize(size);
13
22
  const options = flatten(items);
14
23
  // One stable callback, so the trigger ref is not detached on every render.
@@ -19,11 +28,16 @@ export const Select = function Select({ options: items, value, defaultValue, onV
19
28
  else if (target)
20
29
  target.current = node;
21
30
  }, [ref, triggerRef]);
22
- const [localValue, setLocalValue] = React.useState(defaultValue ?? options[0]?.value ?? '');
31
+ const many = allProps.multiple === true ? allProps : undefined;
32
+ const one = allProps.multiple === true ? undefined : allProps;
33
+ const initialValue = many
34
+ ? (many.defaultValue ?? [])
35
+ : (one.defaultValue ?? options[0]?.value ?? '');
36
+ const [localValue, setLocalValue] = React.useState(initialValue);
23
37
  const nativeInput = React.useRef(null);
24
- const initialValue = defaultValue ?? options[0]?.value ?? '';
25
- const resetCallback = React.useRef(onValueChange);
26
- resetCallback.current = onValueChange;
38
+ const resetCallback = React.useRef(undefined);
39
+ resetCallback.current = allProps.onValueChange;
40
+ const resetKey = many ? initialValue.toString() : initialValue;
27
41
  React.useEffect(() => {
28
42
  const owner = nativeInput.current?.form;
29
43
  if (!owner)
@@ -35,7 +49,9 @@ export const Select = function Select({ options: items, value, defaultValue, onV
35
49
  if (event.defaultPrevented)
36
50
  return;
37
51
  setLocalValue(initialValue);
38
- resetCallback.current?.(initialValue);
52
+ resetCallback.current?.((Array.isArray(initialValue)
53
+ ? [...initialValue]
54
+ : initialValue));
39
55
  }, 0);
40
56
  pending.add(timer);
41
57
  };
@@ -44,20 +60,77 @@ export const Select = function Select({ options: items, value, defaultValue, onV
44
60
  owner.removeEventListener('reset', reset);
45
61
  pending.forEach(clearTimeout);
46
62
  };
47
- }, [initialValue, props.form]);
48
- const selected = value ?? localValue;
63
+ // initialValue is read through resetKey, so a new array of the same values
64
+ // does not re-register the listener.
65
+ }, [resetKey, props.form]);
49
66
  const { name, form, required, disabled, id, ...aria } = props;
50
- return (_jsx("span", { className: cx('surf-select', className), "data-size": sizeName(size), "data-ui-size": scheme, "data-disabled": disabled || undefined, children: _jsxs(BaseSelect.Root, { items: options, value: selected, onValueChange: (v) => {
51
- setLocalValue(v ?? '');
52
- onValueChange?.(v ?? '');
53
- }, inputRef: (node) => {
54
- nativeInput.current = node;
55
- if (typeof inputRef === 'function')
56
- inputRef(node);
57
- else if (inputRef)
58
- inputRef.current = node;
59
- }, name: name, form: form, required: required, disabled: disabled, id: id, children: [_jsxs(BaseSelect.Trigger, { ...aria, ref: setTrigger, className: 'surf-select-trigger', "data-value": selected, children: [_jsx(BaseSelect.Value, {}), _jsx(BaseSelect.Icon, { className: 'surf-select-icon', children: _jsx(Icon, { name: 'chevron-down' }) })] }), _jsx(BaseSelect.Portal, { children: _jsx(BaseSelect.Positioner, { className: 'surf-positioner', sideOffset: 6, alignItemWithTrigger: false, children: _jsx(BaseSelect.Popup, { className: 'surf-select-popup', "data-ui-size": scheme, children: _jsx(BaseSelect.List, { children: items.map((item, index) => isGroup(item) ? (_jsxs(BaseSelect.Group, { className: 'surf-select-group', children: [_jsx(BaseSelect.GroupLabel, { className: 'surf-select-group-label', children: item.label }), item.options.map(renderOption)] }, index)) : (renderOption(item))) }) }) }) })] }) }));
67
+ const setInput = (node) => {
68
+ nativeInput.current = node;
69
+ if (typeof inputRef === 'function')
70
+ inputRef(node);
71
+ else if (inputRef)
72
+ inputRef.current = node;
73
+ };
74
+ // Several values: the "all" option leads the list, and the trigger sums them up.
75
+ const choosable = options.filter((option) => !option.disabled);
76
+ const text = { ...multipleLabels, ...(many?.labels ?? {}) };
77
+ const chosen = many
78
+ ? (many.value ?? localValue)
79
+ : [];
80
+ const everything = choosable.length > 0 &&
81
+ choosable.every((option) => chosen.includes(option.value));
82
+ const summary = (values) => values.length === 0
83
+ ? text.none
84
+ : choosable.every((option) => values.includes(option.value))
85
+ ? text.all
86
+ : values.length === 1
87
+ ? options.find((option) => option.value === values[0])?.label
88
+ : text.count(values.length);
89
+ const selected = many
90
+ ? everything
91
+ ? [ALL, ...chosen]
92
+ : chosen
93
+ : (one.value ?? localValue);
94
+ const value = many ? summary(chosen) : _jsx(BaseSelect.Value, {});
95
+ // Content width: every label the trigger can show, stacked in one cell.
96
+ const sizes = many
97
+ ? [
98
+ text.all,
99
+ text.none,
100
+ text.count(options.length),
101
+ ...options.map((option) => option.label),
102
+ ]
103
+ : options.map((option) => option.label);
104
+ return (_jsx("span", { className: cx('surf-select', className), "data-size": sizeName(size), "data-ui-size": scheme, "data-width": width, "data-disabled": disabled || undefined, children: _jsxs(BaseSelect.Root, { items: options, multiple: multiple, value: selected, onValueChange: (next) => {
105
+ if (many) {
106
+ const list = next ?? [];
107
+ const toggledAll = list.includes(ALL) !== everything;
108
+ const values = toggledAll
109
+ ? everything
110
+ ? []
111
+ : choosable.map((option) => option.value)
112
+ : // Unchecking one option of "all" leaves the others.
113
+ list.filter((item) => item !== ALL);
114
+ setLocalValue(values);
115
+ many.onValueChange?.(values);
116
+ }
117
+ else {
118
+ setLocalValue(next ?? '');
119
+ one.onValueChange?.(next ?? '');
120
+ }
121
+ }, inputRef: setInput,
122
+ // A multiple Select submits its own inputs, without the "all" value.
123
+ name: many ? undefined : name, form: form, required: required, disabled: disabled, id: id, children: [_jsxs(BaseSelect.Trigger, { ...aria, ref: setTrigger, className: 'surf-select-trigger', "data-value": many ? chosen.join(' ') : selected, children: [width === 'content' ? (
124
+ // Every label sits in one cell, hidden but the chosen one, so the
125
+ // trigger is as wide as the longest.
126
+ _jsxs("span", { className: 'surf-select-sizer', children: [_jsx("span", { className: 'surf-select-value', children: value }), sizes.map((label, index) => (_jsx("span", { "aria-hidden": 'true', children: label }, index)))] })) : many ? (_jsx("span", { className: 'surf-select-value', children: value })) : (value), _jsx(BaseSelect.Icon, { className: 'surf-select-icon', children: _jsx(Icon, { name: 'chevron-down' }) })] }), many &&
127
+ name &&
128
+ chosen.map((item) => (_jsx("input", { type: 'hidden', name: name, value: item, form: form }, item))), _jsx(BaseSelect.Portal, { children: _jsx(BaseSelect.Positioner, { className: 'surf-positioner', sideOffset: 6, alignItemWithTrigger: false, children: _jsx(BaseSelect.Popup, { className: 'surf-select-popup', "data-ui-size": scheme, children: _jsxs(BaseSelect.List, { children: [many && (_jsxs(_Fragment, { children: [renderOption({
129
+ value: ALL,
130
+ label: text.all,
131
+ disabled: choosable.length === 0,
132
+ }), _jsx(BaseSelect.Separator, { className: 'surf-select-separator' })] })), items.map((item, index) => isGroup(item) ? (_jsxs(BaseSelect.Group, { className: 'surf-select-group', children: [_jsx(BaseSelect.GroupLabel, { className: 'surf-select-group-label', children: item.label }), item.options.map(renderOption)] }, index)) : (renderOption(item)))] }) }) }) })] }) }));
60
133
  };
61
134
  function renderOption(option) {
62
- return (_jsxs(BaseSelect.Item, { className: 'surf-select-item', value: option.value, "data-option-value": option.value, disabled: option.disabled, children: [_jsx(BaseSelect.ItemIndicator, { className: 'surf-select-check', children: _jsx(Icon, { name: 'check' }) }), _jsx(BaseSelect.ItemText, { children: option.label })] }, option.value));
135
+ return (_jsxs(BaseSelect.Item, { className: 'surf-select-item', value: option.value, "data-option-value": option.value === ALL ? undefined : option.value, disabled: option.disabled, children: [_jsx(BaseSelect.ItemIndicator, { className: 'surf-select-check', children: _jsx(Icon, { name: 'check' }) }), _jsx(BaseSelect.ItemText, { children: option.label })] }, option.value));
63
136
  }
package/dist/stat.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import * as React from 'react';
2
+ import { type StatusTone } from './status.js';
2
3
  export type StatDirection = 'up' | 'down' | 'flat';
3
4
  export type StatChangeProps = Omit<React.ComponentPropsWithoutRef<'dd'>, 'children'> & {
4
5
  /** The change, such as 0.023 for 2.3%. Its sign sets the direction. */
@@ -11,6 +12,15 @@ export type StatChangeProps = Omit<React.ComponentPropsWithoutRef<'dd'>, 'childr
11
12
  * still points the way the value moved. */
12
13
  lowerIsBetter?: boolean;
13
14
  };
15
+ export type StatStatusProps = React.ComponentPropsWithoutRef<'dd'> & {
16
+ /** The value's state: caution for one to watch, danger for one past its limit. */
17
+ tone: StatusTone;
18
+ };
19
+ export type StatGroupProps = React.ComponentPropsWithoutRef<'div'> & {
20
+ /** What every value in the group measures, such as "GPU temperature". It shows
21
+ * above them and names the group. */
22
+ label: string;
23
+ };
14
24
  /** A key figure: what it measures, its value, how it moved and what it covers. Renders
15
25
  * a description list, so the label names the value for assistive technology. */
16
26
  export declare const Stat: {
@@ -29,10 +39,16 @@ export declare const Stat: {
29
39
  Change: ({ value, format, direction, lowerIsBetter, className, ref, ...props }: StatChangeProps & {
30
40
  ref?: React.Ref<HTMLElement>;
31
41
  }) => React.JSX.Element;
42
+ Status: ({ tone, className, children, ref, ...props }: StatStatusProps & {
43
+ ref?: React.Ref<HTMLElement>;
44
+ }) => React.JSX.Element;
32
45
  Caption: ({ className, size, ref, ...rest }: Omit<Omit<React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement>, "ref">, "size"> & {
33
46
  size?: import("./size.js").ComponentSize;
34
47
  ref?: React.Ref<HTMLElement> | undefined;
35
48
  }) => React.JSX.Element;
49
+ Group: ({ label, className, children, ref, ...props }: StatGroupProps & {
50
+ ref?: React.Ref<HTMLDivElement>;
51
+ }) => React.JSX.Element;
36
52
  };
37
53
  export declare const StatRoot: ({ className, size, ref, ...rest }: Omit<Omit<React.DetailedHTMLProps<React.HTMLAttributes<HTMLDListElement>, HTMLDListElement>, "ref">, "size"> & {
38
54
  size?: import("./size.js").ComponentSize;
@@ -45,7 +61,11 @@ export declare const StatRoot: ({ className, size, ref, ...rest }: Omit<Omit<Rea
45
61
  ref?: React.Ref<HTMLElement> | undefined;
46
62
  }) => React.JSX.Element, StatChange: ({ value, format, direction, lowerIsBetter, className, ref, ...props }: StatChangeProps & {
47
63
  ref?: React.Ref<HTMLElement>;
64
+ }) => React.JSX.Element, StatStatus: ({ tone, className, children, ref, ...props }: StatStatusProps & {
65
+ ref?: React.Ref<HTMLElement>;
48
66
  }) => React.JSX.Element, StatCaption: ({ className, size, ref, ...rest }: Omit<Omit<React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement>, "ref">, "size"> & {
49
67
  size?: import("./size.js").ComponentSize;
50
68
  ref?: React.Ref<HTMLElement> | undefined;
69
+ }) => React.JSX.Element, StatGroup: ({ label, className, children, ref, ...props }: StatGroupProps & {
70
+ ref?: React.Ref<HTMLDivElement>;
51
71
  }) => React.JSX.Element;
package/dist/stat.js CHANGED
@@ -3,6 +3,7 @@ import { Icon } from './icon.js';
3
3
  import { cx } from './class-names.js';
4
4
  import { elementPart } from './element-part.js';
5
5
  import { formatPercent } from './format.js';
6
+ import { StatusIcon } from './status.js';
6
7
  const signed = (value) => formatPercent(value, { signed: true });
7
8
  const arrows = { up: 'arrow-up', down: 'arrow-down', flat: 'minus' };
8
9
  /** A change against an earlier value: an arrow, a signed figure and its direction's
@@ -12,6 +13,17 @@ const ChangePart = function StatChange({ value, format = signed, direction, lowe
12
13
  const tone = way === 'flat' || !lowerIsBetter ? way : way === 'up' ? 'down' : 'up';
13
14
  return (_jsxs("dd", { ...props, ref: ref, "data-direction": way, "data-tone": tone, className: cx('surf-stat-change', className), children: [_jsx(Icon, { name: arrows[way] }), format(value)] }));
14
15
  };
16
+ /** The value's state against a threshold, such as "Below 30 GiB": the tone's icon and
17
+ * text in its color. A caution or danger status colors the value too, and the text
18
+ * says why, so color is never alone. */
19
+ const StatusPart = function StatStatus({ tone, className, children, ref, ...props }) {
20
+ return (_jsxs("dd", { ...props, ref: ref, "data-tone": tone, className: cx('surf-stat-status', className), children: [_jsx(StatusIcon, { tone: tone }), children] }));
21
+ };
22
+ /** Several values of one measure side by side, such as one per node: a shared label
23
+ * over Stat.Roots whose own labels name each. */
24
+ const GroupPart = function StatGroup({ label, className, children, ref, ...props }) {
25
+ return (_jsxs("div", { role: 'group', "aria-label": label, ...props, ref: ref, className: cx('surf-stat-group', className), children: [_jsx("span", { className: 'surf-stat-group-label', "aria-hidden": 'true', children: label }), _jsx("div", { className: 'surf-stat-group-items', children: children })] }));
26
+ };
15
27
  /** A key figure: what it measures, its value, how it moved and what it covers. Renders
16
28
  * a description list, so the label names the value for assistive technology. */
17
29
  export const Stat = {
@@ -19,7 +31,9 @@ export const Stat = {
19
31
  Label: /* @__PURE__ */ elementPart('dt', 'surf-stat-label'),
20
32
  Value: /* @__PURE__ */ elementPart('dd', 'surf-stat-value'),
21
33
  Change: ChangePart,
34
+ Status: StatusPart,
22
35
  Caption: /* @__PURE__ */ elementPart('dd', 'surf-stat-caption'),
36
+ Group: GroupPart,
23
37
  };
24
38
  // Flat names for React Server Components, which cannot dot into client exports.
25
- export const { Root: StatRoot, Label: StatLabel, Value: StatValue, Change: StatChange, Caption: StatCaption, } = Stat;
39
+ export const { Root: StatRoot, Label: StatLabel, Value: StatValue, Change: StatChange, Status: StatStatus, Caption: StatCaption, Group: StatGroup, } = Stat;
@@ -0,0 +1,42 @@
1
+ import * as React from 'react';
2
+ import type { ComponentSizeAlias } from './size.js';
3
+ /** A span of time back from now, or a refresh interval, in milliseconds. */
4
+ export type TimeRangeOption = {
5
+ value: number;
6
+ label: string;
7
+ };
8
+ export declare const timeRangeOptions: readonly TimeRangeOption[];
9
+ /** 0 is off. */
10
+ export declare const refreshIntervalOptions: readonly TimeRangeOption[];
11
+ export type TimeRangePickerLabels = {
12
+ /** The range select's name. */
13
+ range: string;
14
+ /** The refresh interval select's name. */
15
+ refresh: string;
16
+ /** The button that refreshes now. */
17
+ refreshNow: string;
18
+ };
19
+ export type TimeRangePickerProps = Omit<React.ComponentPropsWithoutRef<'div'>, 'children'> & {
20
+ /** Names the group, such as "Dashboard time". */
21
+ label: string;
22
+ /** The span back from now, in ms; one of ranges. */
23
+ range: number;
24
+ onRangeChange: (range: number) => void;
25
+ /** The span options, such as timeRangeOptions with translated labels. */
26
+ ranges?: readonly TimeRangeOption[];
27
+ /** How often onRefresh runs, in ms; 0 is off. Leave it out for no interval select. */
28
+ refreshInterval?: number;
29
+ onRefreshIntervalChange?: (interval: number) => void;
30
+ refreshIntervals?: readonly TimeRangeOption[];
31
+ /** Runs when the button is pressed and on every interval while the page is shown.
32
+ * Leave it out for no refresh button. */
33
+ onRefresh?: () => void;
34
+ labels?: Partial<TimeRangePickerLabels>;
35
+ size?: ComponentSizeAlias;
36
+ };
37
+ /** The span a monitoring screen shows, back from now, with how often it refreshes and
38
+ * a button to refresh now. For a fixed span between two dates, put a DateRangePicker
39
+ * beside it. */
40
+ export declare const TimeRangePicker: ({ label, range, onRangeChange, ranges, refreshInterval, onRefreshIntervalChange, refreshIntervals: intervals, onRefresh, labels, size, className, ref, ...props }: TimeRangePickerProps & {
41
+ ref?: React.Ref<HTMLDivElement>;
42
+ }) => React.JSX.Element;
@@ -0,0 +1,69 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import * as React from 'react';
4
+ import { Button } from './button.js';
5
+ import { Icon } from './icon.js';
6
+ import { Select } from './select.js';
7
+ import { cx } from './class-names.js';
8
+ const MINUTE = 60_000, HOUR = 60 * MINUTE, DAY = 24 * HOUR;
9
+ export const timeRangeOptions = [
10
+ { value: 15 * MINUTE, label: 'Last 15 minutes' },
11
+ { value: HOUR, label: 'Last hour' },
12
+ { value: 6 * HOUR, label: 'Last 6 hours' },
13
+ { value: DAY, label: 'Last 24 hours' },
14
+ { value: 7 * DAY, label: 'Last 7 days' },
15
+ { value: 30 * DAY, label: 'Last 30 days' },
16
+ ];
17
+ /** 0 is off. */
18
+ export const refreshIntervalOptions = [
19
+ { value: 0, label: 'Off' },
20
+ { value: 10_000, label: 'Every 10 seconds' },
21
+ { value: 30_000, label: 'Every 30 seconds' },
22
+ { value: MINUTE, label: 'Every minute' },
23
+ { value: 5 * MINUTE, label: 'Every 5 minutes' },
24
+ ];
25
+ const pickerLabels = {
26
+ range: 'Time range',
27
+ refresh: 'Refresh interval',
28
+ refreshNow: 'Refresh now',
29
+ };
30
+ /** The span a monitoring screen shows, back from now, with how often it refreshes and
31
+ * a button to refresh now. For a fixed span between two dates, put a DateRangePicker
32
+ * beside it. */
33
+ export const TimeRangePicker = function TimeRangePicker({ label, range, onRangeChange, ranges = timeRangeOptions, refreshInterval, onRefreshIntervalChange, refreshIntervals: intervals = refreshIntervalOptions, onRefresh, labels, size, className, ref, ...props }) {
34
+ const text = { ...pickerLabels, ...labels };
35
+ const refresh = React.useRef(onRefresh);
36
+ refresh.current = onRefresh;
37
+ // The interval pauses while the page is hidden, and refreshes once it shows again.
38
+ React.useEffect(() => {
39
+ if (!refreshInterval || !refresh.current)
40
+ return;
41
+ let due = false;
42
+ const timer = setInterval(() => {
43
+ if (document.hidden)
44
+ due = true;
45
+ else
46
+ refresh.current?.();
47
+ }, refreshInterval);
48
+ const shown = () => {
49
+ if (!document.hidden && due) {
50
+ due = false;
51
+ refresh.current?.();
52
+ }
53
+ };
54
+ document.addEventListener('visibilitychange', shown);
55
+ return () => {
56
+ clearInterval(timer);
57
+ document.removeEventListener('visibilitychange', shown);
58
+ };
59
+ }, [refreshInterval]);
60
+ const options = (list) => list.map((option) => ({
61
+ value: String(option.value),
62
+ label: option.label,
63
+ }));
64
+ return (_jsxs("div", { role: 'group', "aria-label": label, ...props, ref: ref, className: cx('surf-time-range', className), children: [_jsx(Select, { width: 'content', size: size, options: options(ranges), value: String(range), onValueChange: (value) => onRangeChange(Number(value)), "aria-label": text.range }), refreshInterval !== undefined && (_jsx(Select, { width: 'content', size: size, options: options(intervals), value: String(refreshInterval), onValueChange: (value) => onRefreshIntervalChange?.(Number(value)), "aria-label": text.refresh })), onRefresh && (_jsx(Button, { variant: 'outline', size: size === 'small' || size === 'sm'
65
+ ? 'icon-small'
66
+ : size === 'large' || size === 'lg'
67
+ ? 'icon-large'
68
+ : 'icon', "aria-label": text.refreshNow, onClick: () => onRefresh(), children: _jsx(Icon, { name: 'refresh' }) }))] }));
69
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@surflane/ui",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "Surflane React components built on Base UI, with data, chart, code and Markdown components.",
5
5
  "keywords": [
6
6
  "design-system",
@@ -90,6 +90,9 @@
90
90
  "./list.css": "./dist/css/list.css",
91
91
  "./stat.css": "./dist/css/stat.css",
92
92
  "./sparkline.css": "./dist/css/sparkline.css",
93
+ "./bar-list.css": "./dist/css/bar-list.css",
94
+ "./dashboard.css": "./dist/css/dashboard.css",
95
+ "./time-range-picker.css": "./dist/css/time-range-picker.css",
93
96
  "./tree.css": "./dist/css/tree.css",
94
97
  "./chart.css": "./dist/css/chart.css",
95
98
  "./scroll-area.css": "./dist/css/scroll-area.css",
@@ -316,6 +319,18 @@
316
319
  "types": "./dist/sparkline.d.ts",
317
320
  "import": "./dist/sparkline.js"
318
321
  },
322
+ "./bar-list": {
323
+ "types": "./dist/bar-list.d.ts",
324
+ "import": "./dist/bar-list.js"
325
+ },
326
+ "./dashboard": {
327
+ "types": "./dist/dashboard.d.ts",
328
+ "import": "./dist/dashboard.js"
329
+ },
330
+ "./time-range-picker": {
331
+ "types": "./dist/time-range-picker.d.ts",
332
+ "import": "./dist/time-range-picker.js"
333
+ },
319
334
  "./tree": {
320
335
  "types": "./dist/tree.d.ts",
321
336
  "import": "./dist/tree.js"
@@ -406,8 +421,8 @@
406
421
  "@base-ui/react": "^1.9.0",
407
422
  "@headless-tree/core": "^1.8.0",
408
423
  "@headless-tree/react": "^1.8.0",
409
- "@surflane/css": "^0.11.0",
410
- "@surflane/design-tokens": "^0.11.0",
424
+ "@surflane/css": "^0.12.0",
425
+ "@surflane/design-tokens": "^0.12.0",
411
426
  "@tanstack/react-virtual": "^3.14.21",
412
427
  "react": "^19.0.0",
413
428
  "react-aria-components": "^1.22.1",