@giraffesyo/timeclock 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -48,7 +48,7 @@ function HeaderClock() {
48
48
  }
49
49
  ```
50
50
 
51
- `useClock()` gives the state (`status`, `running`, `projects`, `requireProject`, `locked`, `timeZone`, `busy`), what is being typed (`note`, `setNote`, `projectId`), and what can be done (`start`, `stop`, `chooseProject`, `saveNote`, `resume`). Everything that changes the clock returns a promise that rejects with a `ClockError` when Timeclock refuses.
51
+ `useClock()` gives the state (`status`, `running`, `projects`, `requireProject`, `requireDescription`, `locked`, `timeZone`, `busy`), what is being typed (`note`, `setNote`, `projectId`), and what can be done (`start`, `stop`, `chooseProject`, `saveNote`, `resume`). Everything that changes the clock returns a promise that rejects with a `ClockError` when Timeclock refuses.
52
52
 
53
53
  ## The store
54
54
 
package/dist/index.d.ts CHANGED
@@ -25,6 +25,7 @@ export interface ClockState {
25
25
  projects: Project[];
26
26
  /** Whether the organization requires a project on every entry. */
27
27
  requireProject: boolean;
28
+ requireDescription: boolean;
28
29
  /** The pay period's timesheet is in, so the clock is off. */
29
30
  locked: boolean;
30
31
  /** The IANA time zone the person's times are shown in. */
package/dist/index.js CHANGED
@@ -21,6 +21,7 @@ const initial = {
21
21
  running: null,
22
22
  projects: [],
23
23
  requireProject: false,
24
+ requireDescription: false,
24
25
  locked: false,
25
26
  timeZone: 'UTC',
26
27
  busy: false,
@@ -74,6 +75,7 @@ export function createClock(options = {}) {
74
75
  running: me.running ?? null,
75
76
  projects: projects.projects ?? [],
76
77
  requireProject: me.settings.requireProject,
78
+ requireDescription: me.settings.requireDescription ?? false,
77
79
  locked: status === 'submitted' || status === 'approved',
78
80
  timeZone: me.person.timezone || me.settings.timezone,
79
81
  });
package/dist/react.d.ts CHANGED
@@ -74,6 +74,9 @@ export interface ClockBarLabels extends ProjectPickerLabels {
74
74
  locked: string;
75
75
  /** When the running stretch started, given the time as text. */
76
76
  since: (time: string) => string;
77
+ missingDescription: string;
78
+ missingProject: string;
79
+ missingDescriptionAndProject: string;
77
80
  }
78
81
  /** What the person was doing when something was refused. */
79
82
  export type ClockAction = 'start' | 'stop' | 'switch' | 'note';
package/dist/react.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  // The clock for React: useClock() is the logic with no look, and ClockBar
3
3
  // and ProjectPicker are a look for it, styled by "@giraffesyo/timeclock/styles.css".
4
- import { useEffect, useId, useRef, useState, useSyncExternalStore, } from 'react';
4
+ import { useEffect, useId, useLayoutEffect, useRef, useState, useSyncExternalStore, } from 'react';
5
5
  import { clockFor, projectHue, projectLabel, stopwatch, } from './index.js';
6
6
  /** The clock of the Timeclock at a base path, kept current while the component is mounted. */
7
7
  export function useClock(options = {}) {
@@ -33,7 +33,10 @@ export function useClock(options = {}) {
33
33
  }
34
34
  // A note typed just before choosing a project describes the work being
35
35
  // moved to, so it goes with the new stretch.
36
- return store.switchTo({ projectId, note: noteEdited ? note : '' });
36
+ return store.switchTo({
37
+ projectId,
38
+ note: noteEdited || state.requireDescription || !running.projectId ? note : '',
39
+ });
37
40
  },
38
41
  saveNote,
39
42
  start: () => store.start({ projectId: current.projectId, note }),
@@ -77,6 +80,7 @@ export function ProjectPicker({ projects, value, onChange, required = false, dis
77
80
  const listId = useId();
78
81
  const root = useRef(null);
79
82
  const trigger = useRef(null);
83
+ const menu = useRef(null);
80
84
  const [open, setOpen] = useState(false);
81
85
  const [query, setQuery] = useState('');
82
86
  const [cursor, setCursor] = useState(0);
@@ -87,6 +91,41 @@ export function ProjectPicker({ projects, value, onChange, required = false, dis
87
91
  ...projects.map((p) => ({ id: p.id, name: p.name, group: p.customerName })),
88
92
  ].filter((o) => words.every((w) => `${o.group} ${o.name}`.toLowerCase().includes(w)));
89
93
  const at = Math.min(cursor, Math.max(options.length - 1, 0));
94
+ // A nested native popover escapes a dialog's scrolling/clipping bounds while
95
+ // remaining its descendant, so selecting a project keeps the editor open.
96
+ useLayoutEffect(() => {
97
+ const el = menu.current;
98
+ const button = trigger.current;
99
+ if (!open || !el || !button)
100
+ return;
101
+ el.showPopover();
102
+ const position = () => {
103
+ const rect = button.getBoundingClientRect();
104
+ const below = window.innerHeight - rect.bottom - 12;
105
+ const above = rect.top - 12;
106
+ el.style.maxHeight = `${Math.max(0, Math.max(below, above))}px`;
107
+ const height = el.offsetHeight;
108
+ const down = below >= height || below >= above;
109
+ el.style.maxHeight = `${Math.max(0, down ? below : above)}px`;
110
+ el.style.left = `${Math.max(8, Math.min(rect.left, window.innerWidth - el.offsetWidth - 8))}px`;
111
+ el.style.top = `${down ? rect.bottom + 4 : Math.max(8, rect.top - el.offsetHeight - 4)}px`;
112
+ };
113
+ position();
114
+ el.querySelector('input')?.focus({ preventScroll: true });
115
+ const observer = new ResizeObserver(position);
116
+ observer.observe(el);
117
+ const follow = (e) => {
118
+ if (e.target instanceof Node && !el.contains(e.target))
119
+ position();
120
+ };
121
+ window.addEventListener('resize', position);
122
+ window.addEventListener('scroll', follow, true);
123
+ return () => {
124
+ observer.disconnect();
125
+ window.removeEventListener('resize', position);
126
+ window.removeEventListener('scroll', follow, true);
127
+ };
128
+ }, [open]);
90
129
  // A press outside closes the list.
91
130
  useEffect(() => {
92
131
  if (!open)
@@ -127,6 +166,7 @@ export function ProjectPicker({ projects, value, onChange, required = false, dis
127
166
  break;
128
167
  }
129
168
  case 'Escape':
169
+ e.preventDefault();
130
170
  // The list closes; a dialog around it stays.
131
171
  e.stopPropagation();
132
172
  e.nativeEvent.stopImmediatePropagation();
@@ -144,12 +184,18 @@ export function ProjectPicker({ projects, value, onChange, required = false, dis
144
184
  e.preventDefault();
145
185
  show();
146
186
  }
147
- }, children: [_jsx(ProjectDot, { projectId: current?.id }), _jsx("span", { className: cx('tc-picker-value', !current && 'tc-muted'), children: shown }), _jsx("svg", { "aria-hidden": true, className: "tc-chevron", viewBox: "0 0 12 12", fill: "none", stroke: "currentColor", strokeWidth: "1.75", children: _jsx("path", { d: "M2.5 4.5 6 8l3.5-3.5", strokeLinecap: "round", strokeLinejoin: "round" }) })] }), open && (_jsxs("div", { className: "tc-popover", children: [_jsx("input", { className: "tc-search", role: "combobox", "aria-expanded": true, "aria-controls": listId, "aria-activedescendant": options[at] ? `${listId}-${at}` : undefined, "aria-label": labels.search, placeholder: labels.search, value: query,
148
- // biome-ignore lint/a11y/noAutofocus: opening the list is asking to search it
149
- autoFocus: true, onChange: (e) => {
187
+ }, children: [_jsx(ProjectDot, { projectId: current?.id }), _jsx("span", { className: cx('tc-picker-value', !current && 'tc-muted'), children: shown }), _jsx("svg", { "aria-hidden": true, className: "tc-chevron", viewBox: "0 0 12 12", fill: "none", stroke: "currentColor", strokeWidth: "1.75", children: _jsx("path", { d: "M2.5 4.5 6 8l3.5-3.5", strokeLinecap: "round", strokeLinejoin: "round" }) })] }), open && (_jsxs("div", { ref: menu, popover: "auto", className: "tc-popover", onToggle: (e) => {
188
+ if (e.target === e.currentTarget && e.newState === 'closed')
189
+ setOpen(false);
190
+ }, children: [_jsx("input", { className: "tc-search", role: "combobox", "aria-expanded": true, "aria-controls": listId, "aria-activedescendant": options[at] ? `${listId}-${at}` : undefined, "aria-label": labels.search, placeholder: labels.search, value: query, onChange: (e) => {
150
191
  setQuery(e.target.value);
151
192
  setCursor(0);
152
- }, onKeyDown: onKeyDown }), _jsxs("div", { id: listId, role: "listbox", "aria-label": label ?? labels.project, className: "tc-options", children: [options.length === 0 && _jsx("p", { className: "tc-empty", children: labels.noMatch }), options.map((o, i) => (_jsxs("div", { children: [o.group !== (options[i - 1]?.group ?? '') && _jsx("div", { className: "tc-group", children: o.group }), _jsxs("div", { id: `${listId}-${i}`, role: "option", tabIndex: -1, "aria-selected": o.id === value, className: cx('tc-option', i === at && 'tc-option-active'), onPointerMove: () => setCursor(i), onClick: () => pick(o.id), children: [_jsx(ProjectDot, { projectId: o.id }), _jsx("span", { className: "tc-option-name", children: o.name }), o.id === value && (_jsx("svg", { "aria-hidden": true, className: "tc-check", viewBox: "0 0 12 12", fill: "none", stroke: "currentColor", strokeWidth: "2", children: _jsx("path", { d: "m2.5 6.5 2.5 2.5 4.5-5.5", strokeLinecap: "round", strokeLinejoin: "round" }) }))] })] }, o.id)))] })] }))] }));
193
+ }, onKeyDown: onKeyDown }), _jsxs("div", { id: listId, role: "listbox", "aria-label": label ?? labels.project, className: "tc-options", children: [options.length === 0 && _jsx("p", { className: "tc-empty", children: labels.noMatch }), options.map((o, i) => (_jsxs("div", { children: [o.group !== (options[i - 1]?.group ?? '') && _jsx("div", { className: "tc-group", children: o.group }), _jsxs("div", { id: `${listId}-${i}`, role: "option", tabIndex: -1, "aria-selected": o.id === value, className: cx('tc-option', i === at && 'tc-option-active'), onPointerMove: () => setCursor(i), onClick: (e) => {
194
+ // A picker can sit inside a field label. Selecting its row
195
+ // must not activate the labelled trigger and reopen the menu.
196
+ e.preventDefault();
197
+ pick(o.id);
198
+ }, children: [_jsx(ProjectDot, { projectId: o.id }), _jsx("span", { className: "tc-option-name", children: o.name }), o.id === value && (_jsx("svg", { "aria-hidden": true, className: "tc-check", viewBox: "0 0 12 12", fill: "none", stroke: "currentColor", strokeWidth: "2", children: _jsx("path", { d: "m2.5 6.5 2.5 2.5 4.5-5.5", strokeLinecap: "round", strokeLinejoin: "round" }) }))] })] }, o.id)))] })] }))] }));
153
199
  }
154
200
  const barLabels = {
155
201
  ...pickerLabels,
@@ -161,6 +207,9 @@ const barLabels = {
161
207
  switchProject: 'Project the clock is running on',
162
208
  locked: 'This pay period’s timesheet is submitted, so the clock is off.',
163
209
  since: (time) => `Running since ${time}`,
210
+ missingDescription: 'Add a description to stop the clock and save this time entry.',
211
+ missingProject: 'Choose a project to stop the clock and save this time entry.',
212
+ missingDescriptionAndProject: 'Add a description and choose a project to stop the clock and save this time entry.',
164
213
  };
165
214
  /**
166
215
  * The clock as a bar: say what you are working on, pick its project, and
@@ -175,10 +224,22 @@ export function ClockBar({ basePath, store, labels: given, className, onError, o
175
224
  const { running } = clock;
176
225
  const now = useSecond(!!running);
177
226
  const bar = useRef(null);
227
+ const [stopAttempt, setStopAttempt] = useState(null);
228
+ const errorId = useId();
178
229
  // Whether a press that began inside the bar is under way.
179
230
  const pressed = useRef(false);
180
231
  if (clock.status !== 'ready')
181
232
  return null;
233
+ const missingDescription = clock.requireDescription && !clock.note.trim();
234
+ const missingProject = clock.requireProject && !clock.projectId;
235
+ const missing = missingDescription && missingProject
236
+ ? labels.missingDescriptionAndProject
237
+ : missingDescription
238
+ ? labels.missingDescription
239
+ : missingProject
240
+ ? labels.missingProject
241
+ : null;
242
+ const stopError = running && stopAttempt === running.id ? missing : null;
182
243
  const failed = (action) => (err) => onError?.(err, action);
183
244
  const choose = (projectId) => {
184
245
  clock
@@ -203,6 +264,11 @@ export function ClockBar({ basePath, store, labels: given, className, onError, o
203
264
  : undefined;
204
265
  return (_jsxs("form", { ref: bar, "aria-label": labels.clock, className: cx('tc-bar', className), onSubmit: (e) => {
205
266
  e.preventDefault();
267
+ if (running && missing) {
268
+ setStopAttempt(running.id);
269
+ return;
270
+ }
271
+ setStopAttempt(null);
206
272
  if (running)
207
273
  clock.stop().then(onChanged, failed('stop'));
208
274
  else
@@ -213,11 +279,11 @@ export function ClockBar({ basePath, store, labels: given, className, onError, o
213
279
  pressed.current = false;
214
280
  }, onPointerCancelCapture: () => {
215
281
  pressed.current = false;
216
- }, children: [_jsx("input", { className: "tc-note", value: clock.note, placeholder: clock.locked ? labels.locked : labels.notePlaceholder, "aria-label": labels.noteLabel, disabled: clock.locked, maxLength: 2000, onChange: (e) => clock.setNote(e.target.value), onBlur: running ? leaveNote : undefined, onKeyDown: (e) => {
282
+ }, children: [_jsx("input", { className: "tc-note", value: clock.note, placeholder: clock.locked ? labels.locked : labels.notePlaceholder, "aria-label": labels.noteLabel, disabled: clock.locked, "aria-required": !!running && clock.requireDescription, "aria-invalid": !!stopError && missingDescription, "aria-describedby": stopError && missingDescription ? errorId : undefined, maxLength: 2000, onChange: (e) => clock.setNote(e.target.value), onBlur: running ? leaveNote : undefined, onKeyDown: (e) => {
217
283
  // While the clock runs, Enter is done with the note; it doesn't stop the clock.
218
284
  if (running && e.key === 'Enter') {
219
285
  e.preventDefault();
220
286
  e.currentTarget.blur();
221
287
  }
222
- } }), _jsx(ProjectPicker, { className: "tc-bar-picker", projects: clock.projects, value: clock.projectId, onChange: choose, required: clock.requireProject, disabled: clock.locked || (!!running && clock.busy), label: running ? labels.switchProject : undefined, labels: labels }), running ? (_jsx("span", { className: "tc-time", role: "timer", title: since, children: stopwatch(now - Date.parse(running.startedAt)) })) : (_jsx("span", { "aria-hidden": true, className: "tc-time tc-time-idle", children: stopwatch(0) })), _jsx("button", { type: "submit", "aria-label": running ? labels.stop : labels.start, disabled: clock.locked || clock.busy, className: cx('tc-go', !!running && 'tc-go-stop'), children: _jsx("svg", { "aria-hidden": true, viewBox: "0 0 16 16", fill: "currentColor", children: running ? _jsx("rect", { x: "4", y: "4", width: "8", height: "8", rx: "1" }) : _jsx("path", { d: "M5.5 3.5v9l7-4.5z" }) }) })] }));
288
+ } }), _jsx(ProjectPicker, { className: "tc-bar-picker", projects: clock.projects, value: clock.projectId, onChange: choose, required: clock.requireProject, disabled: clock.locked || (!!running && clock.busy), label: running ? labels.switchProject : undefined, labels: labels }), running ? (_jsx("span", { className: "tc-time", role: "timer", title: since, children: stopwatch(now - Date.parse(running.startedAt)) })) : (_jsx("span", { "aria-hidden": true, className: "tc-time tc-time-idle", children: stopwatch(0) })), _jsx("button", { type: "submit", "aria-describedby": stopError ? errorId : undefined, "aria-label": running ? labels.stop : labels.start, disabled: clock.locked || clock.busy, className: cx('tc-go', !!running && 'tc-go-stop'), children: _jsx("svg", { "aria-hidden": true, viewBox: "0 0 16 16", fill: "currentColor", children: running ? _jsx("rect", { x: "4", y: "4", width: "8", height: "8", rx: "1" }) : _jsx("path", { d: "M5.5 3.5v9l7-4.5z" }) }) }), stopError && (_jsx("p", { id: errorId, role: "alert", className: "tc-stop-error", children: stopError }))] }));
223
289
  }
package/dist/styles.css CHANGED
@@ -97,6 +97,13 @@
97
97
  cursor: not-allowed;
98
98
  opacity: 0.5;
99
99
  }
100
+ .tc-stop-error {
101
+ flex: 1 1 100%;
102
+ margin: 0;
103
+ padding: 0.25rem 0.75rem;
104
+ color: var(--tc-fg);
105
+ font-size: 0.875rem;
106
+ }
100
107
  @media (min-width: 40rem) {
101
108
  .tc-note {
102
109
  flex-basis: 12rem;
@@ -155,13 +162,13 @@
155
162
  color: var(--tc-accent);
156
163
  }
157
164
  .tc-popover {
158
- position: absolute;
159
- top: 100%;
160
- left: 0;
161
- z-index: 40;
165
+ position: fixed;
166
+ inset: auto;
167
+ padding: 0;
168
+ color: var(--tc-fg);
162
169
  width: 18rem;
163
170
  max-width: calc(100vw - 2rem);
164
- margin-top: 0.25rem;
171
+ margin: 0;
165
172
  border: 1px solid var(--tc-border);
166
173
  border-radius: 0.625rem;
167
174
  background: var(--tc-bg);
@@ -169,7 +176,12 @@
169
176
  0 10px 28px -10px rgb(0 0 0 / 0.3),
170
177
  0 2px 6px -2px rgb(0 0 0 / 0.12);
171
178
  }
179
+ .tc-popover:popover-open {
180
+ display: flex;
181
+ flex-direction: column;
182
+ }
172
183
  .tc-search {
184
+ flex-shrink: 0;
173
185
  width: 100%;
174
186
  padding: 0.5rem 0.75rem;
175
187
  border: 0;
@@ -185,6 +197,7 @@
185
197
  color: var(--tc-muted);
186
198
  }
187
199
  .tc-options {
200
+ min-height: 0;
188
201
  max-height: 16rem;
189
202
  overflow-y: auto;
190
203
  padding: 0.25rem;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@giraffesyo/timeclock",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "The Timeclock clock for a host application: a headless store and React hook, and a ready-made bar.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {