@jskit-ai/users-web 0.1.134 → 0.1.136

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,107 @@
1
+ <script setup>
2
+ import { computed } from "vue";
3
+ import CrudListFilterSurface from "../../src/client/components/CrudListFilterSurface.vue";
4
+ import { useCrudListFilters } from "../../src/client/composables/useCrudListFilters.js";
5
+
6
+ const filters = {
7
+ status: {
8
+ key: "status",
9
+ queryKey: "status",
10
+ type: "enum",
11
+ label: "Status",
12
+ options: [
13
+ { value: "active", label: "Active" },
14
+ { value: "archived", label: "Archived" }
15
+ ]
16
+ },
17
+ submittedOn: {
18
+ key: "submittedOn",
19
+ queryKey: "submittedOn",
20
+ type: "date",
21
+ label: "Submitted date"
22
+ },
23
+ arrivalDate: {
24
+ key: "arrivalDate",
25
+ queryKey: "arrivalDate",
26
+ type: "dateRange",
27
+ label: "Arrival"
28
+ },
29
+ assignment: {
30
+ key: "assignment",
31
+ queryKey: "assignment",
32
+ type: "presence",
33
+ label: "Assignment",
34
+ options: [
35
+ { value: "present", label: "Assigned" },
36
+ { value: "missing", label: "Unassigned" }
37
+ ]
38
+ }
39
+ };
40
+ const runtime = useCrudListFilters(filters);
41
+ const query = new URLSearchParams(window.location.search);
42
+
43
+ for (const [key, param] of Object.entries(runtime.queryParams)) {
44
+ if (query.has(key)) {
45
+ param.value = query.get(key);
46
+ }
47
+ }
48
+
49
+ const contractSnapshot = computed(() => JSON.stringify({
50
+ values: {
51
+ submittedOn: runtime.values.submittedOn,
52
+ arrivalDate: {
53
+ from: runtime.values.arrivalDate.from,
54
+ to: runtime.values.arrivalDate.to
55
+ }
56
+ },
57
+ query: {
58
+ submittedOn: runtime.queryParams.submittedOn.value,
59
+ arrivalDate: runtime.queryParams.arrivalDate.value
60
+ }
61
+ }));
62
+ </script>
63
+
64
+ <template>
65
+ <v-app>
66
+ <v-main>
67
+ <v-container class="date-filter-fixture py-8">
68
+ <div class="mb-6">
69
+ <div class="text-overline text-primary">JSKIT users-web</div>
70
+ <h1 class="text-h4 mb-2">Date filter controls</h1>
71
+ <p class="text-body-1 text-medium-emphasis mb-0">
72
+ Date and date-range filters beside ordinary Vuetify selects.
73
+ </p>
74
+ </div>
75
+
76
+ <CrudListFilterSurface :filters="filters" :runtime="runtime" />
77
+
78
+ <output data-testid="date-filter-contract" class="date-filter-fixture__contract">
79
+ {{ contractSnapshot }}
80
+ </output>
81
+ </v-container>
82
+ </v-main>
83
+ </v-app>
84
+ </template>
85
+
86
+ <style>
87
+ html {
88
+ overflow-x: hidden;
89
+ }
90
+
91
+ body {
92
+ background: rgb(var(--v-theme-background));
93
+ }
94
+
95
+ .date-filter-fixture {
96
+ max-width: 90rem;
97
+ }
98
+
99
+ .date-filter-fixture__contract {
100
+ display: block;
101
+ font-family: monospace;
102
+ height: 1px;
103
+ overflow: hidden;
104
+ position: absolute;
105
+ width: 1px;
106
+ }
107
+ </style>
@@ -0,0 +1,12 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>CRUD date filter visual regression</title>
7
+ </head>
8
+ <body>
9
+ <div id="app"></div>
10
+ <script type="module" src="/main.js"></script>
11
+ </body>
12
+ </html>
@@ -0,0 +1,24 @@
1
+ import { createApp } from "vue";
2
+ import "vuetify/styles";
3
+ import { createVuetify } from "vuetify";
4
+ import * as components from "vuetify/components";
5
+ import * as directives from "vuetify/directives";
6
+ import { aliases as mdiAliases, mdi } from "vuetify/iconsets/mdi-svg";
7
+ import App from "./App.vue";
8
+
9
+ const vuetify = createVuetify({
10
+ components,
11
+ directives,
12
+ theme: {
13
+ defaultTheme: "light"
14
+ },
15
+ icons: {
16
+ defaultSet: "mdi",
17
+ aliases: mdiAliases,
18
+ sets: { mdi }
19
+ }
20
+ });
21
+
22
+ createApp(App)
23
+ .use(vuetify)
24
+ .mount("#app");
@@ -0,0 +1,18 @@
1
+ import path from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+ import { defineConfig } from "vite";
4
+ import vue from "@vitejs/plugin-vue";
5
+
6
+ const fixtureRoot = path.dirname(fileURLToPath(import.meta.url));
7
+
8
+ export default defineConfig({
9
+ root: fixtureRoot,
10
+ plugins: [vue()],
11
+ resolve: {
12
+ preserveSymlinks: true
13
+ },
14
+ server: {
15
+ host: "127.0.0.1",
16
+ strictPort: true
17
+ }
18
+ });
@@ -3,7 +3,7 @@ import { HOME_COG_OUTLET } from "./src/shared/toolsOutletContracts.js";
3
3
  export default Object.freeze({
4
4
  packageVersion: 1,
5
5
  packageId: "@jskit-ai/users-web",
6
- version: "0.1.134",
6
+ version: "0.1.136",
7
7
  kind: "runtime",
8
8
  description: "Users web module: account/profile UI plus shared users web widgets.",
9
9
  dependsOn: [
@@ -286,12 +286,12 @@ export default Object.freeze({
286
286
  dependencies: {
287
287
  runtime: {
288
288
  "@mdi/js": "^7.4.47",
289
- "@jskit-ai/http-runtime": "0.1.118",
290
- "@jskit-ai/realtime": "0.1.117",
291
- "@jskit-ai/kernel": "0.1.120",
292
- "@jskit-ai/shell-web": "0.1.119",
293
- "@jskit-ai/uploads-image-web": "0.1.96",
294
- "@jskit-ai/users-core": "0.1.128"
289
+ "@jskit-ai/http-runtime": "0.1.119",
290
+ "@jskit-ai/realtime": "0.1.118",
291
+ "@jskit-ai/kernel": "0.1.121",
292
+ "@jskit-ai/shell-web": "0.1.120",
293
+ "@jskit-ai/uploads-image-web": "0.1.97",
294
+ "@jskit-ai/users-core": "0.1.130"
295
295
  },
296
296
  dev: {}
297
297
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/users-web",
3
- "version": "0.1.134",
3
+ "version": "0.1.136",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test"
@@ -46,12 +46,12 @@
46
46
  },
47
47
  "dependencies": {
48
48
  "@mdi/js": "^7.4.47",
49
- "@jskit-ai/http-runtime": "0.1.118",
50
- "@jskit-ai/kernel": "0.1.120",
51
- "@jskit-ai/realtime": "0.1.117",
52
- "@jskit-ai/shell-web": "0.1.119",
53
- "@jskit-ai/uploads-image-web": "0.1.96",
54
- "@jskit-ai/users-core": "0.1.128"
49
+ "@jskit-ai/http-runtime": "0.1.119",
50
+ "@jskit-ai/kernel": "0.1.121",
51
+ "@jskit-ai/realtime": "0.1.118",
52
+ "@jskit-ai/shell-web": "0.1.120",
53
+ "@jskit-ai/uploads-image-web": "0.1.97",
54
+ "@jskit-ai/users-core": "0.1.130"
55
55
  },
56
56
  "peerDependencies": {
57
57
  "@tanstack/vue-query": "^5.90.5",
@@ -0,0 +1,188 @@
1
+ <script setup>
2
+ import { computed, nextTick, ref, useAttrs, useId } from "vue";
3
+ import { useLocale } from "vuetify";
4
+ import {
5
+ parseDateOnlyValue,
6
+ formatDateOnlyValue,
7
+ formatDateOnlyDisplay
8
+ } from "../support/crudListDateFilterSupport.js";
9
+
10
+ defineOptions({
11
+ inheritAttrs: false
12
+ });
13
+
14
+ const props = defineProps({
15
+ modelValue: {
16
+ type: String,
17
+ default: ""
18
+ },
19
+ label: {
20
+ type: String,
21
+ required: true
22
+ },
23
+ controlId: {
24
+ type: String,
25
+ default: ""
26
+ },
27
+ placeholder: {
28
+ type: String,
29
+ default: "Select date"
30
+ },
31
+ disabled: {
32
+ type: Boolean,
33
+ default: false
34
+ },
35
+ readonly: {
36
+ type: Boolean,
37
+ default: false
38
+ }
39
+ });
40
+
41
+ const emit = defineEmits(["update:modelValue"]);
42
+ const attrs = useAttrs();
43
+ const { current: currentLocale } = useLocale();
44
+ const generatedId = useId();
45
+ const menuOpen = ref(false);
46
+ const textFieldRef = ref(null);
47
+
48
+ const resolvedControlId = computed(() => props.controlId || `crud-list-date-${generatedId}`);
49
+ const pickerId = computed(() => `${resolvedControlId.value}-picker`);
50
+ const pickerValue = computed(() => parseDateOnlyValue(props.modelValue));
51
+ const displayValue = computed(() => formatDateOnlyDisplay(props.modelValue, {
52
+ locale: currentLocale?.value
53
+ }));
54
+ const pickerTitle = computed(() => `${props.label} calendar`);
55
+
56
+ function restoreFocus() {
57
+ void nextTick(() => {
58
+ textFieldRef.value?.focus?.();
59
+ });
60
+ }
61
+
62
+ function openMenu() {
63
+ if (props.disabled || props.readonly) {
64
+ return;
65
+ }
66
+ menuOpen.value = true;
67
+ }
68
+
69
+ function closeMenu() {
70
+ menuOpen.value = false;
71
+ restoreFocus();
72
+ }
73
+
74
+ function clearDate() {
75
+ if (props.disabled || props.readonly) {
76
+ return;
77
+ }
78
+ emit("update:modelValue", "");
79
+ closeMenu();
80
+ }
81
+
82
+ function selectDate(value) {
83
+ const nextValue = formatDateOnlyValue(value);
84
+ if (!nextValue) {
85
+ return;
86
+ }
87
+ emit("update:modelValue", nextValue);
88
+ closeMenu();
89
+ }
90
+
91
+ function handleActivatorKeydown(event) {
92
+ if (!["Enter", " ", "ArrowDown"].includes(event.key)) {
93
+ return;
94
+ }
95
+ event.preventDefault();
96
+ openMenu();
97
+ }
98
+ </script>
99
+
100
+ <template>
101
+ <v-menu
102
+ v-model="menuOpen"
103
+ :close-on-content-click="false"
104
+ :open-on-click="false"
105
+ location="bottom start"
106
+ :offset="6"
107
+ min-width="0"
108
+ >
109
+ <template #activator="{ props: activatorProps }">
110
+ <v-text-field
111
+ v-bind="{ ...attrs, ...activatorProps }"
112
+ :id="resolvedControlId"
113
+ ref="textFieldRef"
114
+ :model-value="displayValue"
115
+ :label="label"
116
+ :placeholder="placeholder"
117
+ :aria-label="label"
118
+ :aria-controls="pickerId"
119
+ :aria-expanded="String(menuOpen)"
120
+ aria-haspopup="dialog"
121
+ :data-date-filter-control="resolvedControlId"
122
+ append-inner-icon="$calendar"
123
+ :disabled="disabled"
124
+ :clearable="Boolean(modelValue) && !disabled && !readonly"
125
+ persistent-clear
126
+ persistent-placeholder
127
+ readonly
128
+ inputmode="none"
129
+ variant="outlined"
130
+ density="comfortable"
131
+ hide-details="auto"
132
+ class="crud-list-date-filter-control"
133
+ @click:control="openMenu"
134
+ @click:append-inner="openMenu"
135
+ @click:clear.stop="clearDate"
136
+ @keydown="handleActivatorKeydown"
137
+ />
138
+ </template>
139
+
140
+ <v-card
141
+ :id="pickerId"
142
+ :aria-label="pickerTitle"
143
+ role="dialog"
144
+ class="crud-list-date-filter-control__picker"
145
+ @keydown.esc.stop.prevent="closeMenu"
146
+ >
147
+ <v-date-picker
148
+ :model-value="pickerValue"
149
+ :title="pickerTitle"
150
+ :header="label"
151
+ :disabled="disabled || readonly"
152
+ color="primary"
153
+ show-adjacent-months
154
+ width="100%"
155
+ @update:model-value="selectDate"
156
+ />
157
+ <v-divider />
158
+ <v-card-actions>
159
+ <v-btn
160
+ variant="text"
161
+ :disabled="disabled || readonly || !modelValue"
162
+ @click="clearDate"
163
+ >
164
+ Clear
165
+ </v-btn>
166
+ <v-spacer />
167
+ <v-btn color="primary" variant="text" @click="closeMenu">Close</v-btn>
168
+ </v-card-actions>
169
+ </v-card>
170
+ </v-menu>
171
+ </template>
172
+
173
+ <style scoped>
174
+ .crud-list-date-filter-control {
175
+ min-width: 0;
176
+ width: 100%;
177
+ }
178
+
179
+ .crud-list-date-filter-control__picker {
180
+ max-width: calc(100vw - 2rem);
181
+ overflow: hidden;
182
+ width: 22rem;
183
+ }
184
+
185
+ .crud-list-date-filter-control__picker :deep(.v-date-picker) {
186
+ max-width: 100%;
187
+ }
188
+ </style>
@@ -1,6 +1,7 @@
1
1
  <script setup>
2
2
  import { computed, ref } from "vue";
3
3
  import { useDisplay } from "vuetify";
4
+ import CrudListDateFilterControl from "./CrudListDateFilterControl.vue";
4
5
 
5
6
  const props = defineProps({
6
7
  filters: {
@@ -137,22 +138,25 @@ function clearFilters() {
137
138
  hide-details="auto"
138
139
  class="crud-list-filter-surface__control"
139
140
  />
140
- <div v-else-if="filter.type === 'dateRange'" class="crud-list-filter-surface__range">
141
- <v-text-field
141
+ <div
142
+ v-else-if="filter.type === 'dateRange'"
143
+ class="crud-list-filter-surface__range crud-list-filter-surface__range--date"
144
+ role="group"
145
+ :aria-label="`${filter.label} date range`"
146
+ :data-filter-key="filter.key"
147
+ data-filter-type="date-range"
148
+ >
149
+ <CrudListDateFilterControl
142
150
  v-model="runtimeValues[filter.key].from"
143
151
  :label="`${filter.label} from`"
144
- type="date"
145
- variant="outlined"
146
- density="comfortable"
147
- hide-details="auto"
152
+ :control-id="`${filter.key}-from`"
153
+ :placeholder="placeholder(filter, 'Select start date')"
148
154
  />
149
- <v-text-field
155
+ <CrudListDateFilterControl
150
156
  v-model="runtimeValues[filter.key].to"
151
157
  :label="`${filter.label} to`"
152
- type="date"
153
- variant="outlined"
154
- density="comfortable"
155
- hide-details="auto"
158
+ :control-id="`${filter.key}-to`"
159
+ :placeholder="placeholder(filter, 'Select end date')"
156
160
  />
157
161
  </div>
158
162
  <div v-else-if="filter.type === 'numberRange'" class="crud-list-filter-surface__range">
@@ -173,15 +177,12 @@ function clearFilters() {
173
177
  hide-details="auto"
174
178
  />
175
179
  </div>
176
- <v-text-field
180
+ <CrudListDateFilterControl
177
181
  v-else-if="filter.type === 'date'"
178
182
  v-model="runtimeValues[filter.key]"
179
183
  :label="filter.label"
180
- type="date"
181
- clearable
182
- variant="outlined"
183
- density="comfortable"
184
- hide-details="auto"
184
+ :control-id="filter.key"
185
+ :placeholder="placeholder(filter, 'Select date')"
185
186
  class="crud-list-filter-surface__control"
186
187
  />
187
188
  </template>
@@ -244,22 +245,25 @@ function clearFilters() {
244
245
  density="comfortable"
245
246
  hide-details="auto"
246
247
  />
247
- <div v-else-if="filter.type === 'dateRange'" class="crud-list-filter-surface__range">
248
- <v-text-field
248
+ <div
249
+ v-else-if="filter.type === 'dateRange'"
250
+ class="crud-list-filter-surface__range crud-list-filter-surface__range--date"
251
+ role="group"
252
+ :aria-label="`${filter.label} date range`"
253
+ :data-filter-key="filter.key"
254
+ data-filter-type="date-range"
255
+ >
256
+ <CrudListDateFilterControl
249
257
  v-model="runtimeValues[filter.key].from"
250
258
  :label="`${filter.label} from`"
251
- type="date"
252
- variant="outlined"
253
- density="comfortable"
254
- hide-details="auto"
259
+ :control-id="`${filter.key}-from`"
260
+ :placeholder="placeholder(filter, 'Select start date')"
255
261
  />
256
- <v-text-field
262
+ <CrudListDateFilterControl
257
263
  v-model="runtimeValues[filter.key].to"
258
264
  :label="`${filter.label} to`"
259
- type="date"
260
- variant="outlined"
261
- density="comfortable"
262
- hide-details="auto"
265
+ :control-id="`${filter.key}-to`"
266
+ :placeholder="placeholder(filter, 'Select end date')"
263
267
  />
264
268
  </div>
265
269
  <div v-else-if="filter.type === 'numberRange'" class="crud-list-filter-surface__range">
@@ -280,15 +284,12 @@ function clearFilters() {
280
284
  hide-details="auto"
281
285
  />
282
286
  </div>
283
- <v-text-field
287
+ <CrudListDateFilterControl
284
288
  v-else-if="filter.type === 'date'"
285
289
  v-model="runtimeValues[filter.key]"
286
290
  :label="filter.label"
287
- type="date"
288
- clearable
289
- variant="outlined"
290
- density="comfortable"
291
- hide-details="auto"
291
+ :control-id="filter.key"
292
+ :placeholder="placeholder(filter, 'Select date')"
292
293
  />
293
294
  </template>
294
295
  </div>
@@ -337,7 +338,7 @@ function clearFilters() {
337
338
  .crud-list-filter-surface__controls {
338
339
  display: grid;
339
340
  gap: 0.75rem;
340
- grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr));
341
+ grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
341
342
  }
342
343
 
343
344
  .crud-list-filter-surface__controls--stacked {
@@ -352,6 +353,16 @@ function clearFilters() {
352
353
  display: grid;
353
354
  gap: 0.75rem;
354
355
  grid-template-columns: repeat(2, minmax(0, 1fr));
356
+ min-width: 0;
357
+ }
358
+
359
+ .crud-list-filter-surface__range--date {
360
+ grid-column: span 2;
361
+ grid-template-columns: repeat(2, minmax(14rem, 1fr));
362
+ }
363
+
364
+ .crud-list-filter-surface__controls--stacked .crud-list-filter-surface__range--date {
365
+ grid-column: auto;
355
366
  }
356
367
 
357
368
  .crud-list-filter-surface__sheet {
@@ -13,6 +13,7 @@ import {
13
13
  resolveCrudListFilterOptionLabel,
14
14
  CRUD_LIST_FILTER_TYPE_FLAG
15
15
  } from "@jskit-ai/kernel/shared/support/crudListFilters";
16
+ import { formatCrudListDateFilterChipLabel } from "../support/crudListDateFilterSupport.js";
16
17
 
17
18
  function normalizeFunctionMap(value = {}) {
18
19
  const source = value && typeof value === "object" && !Array.isArray(value)
@@ -147,6 +148,15 @@ function resolveAtomicValueLabel(filter = {}, value = "", labelResolvers = {}) {
147
148
  });
148
149
  }
149
150
 
151
+ function formatDefaultChipLabel(filter = {}, chipValue, labelResolvers = {}) {
152
+ return formatCrudListDateFilterChipLabel(filter, chipValue)
153
+ || formatCrudListFilterDefaultChipLabel(filter, chipValue, {
154
+ resolveAtomicValue(value) {
155
+ return resolveAtomicValueLabel(filter, value, labelResolvers);
156
+ }
157
+ });
158
+ }
159
+
150
160
  function useCrudListFilters(definitions = {}, { labelResolvers = {}, chipLabels = {}, presets = [] } = {}) {
151
161
  const filters = defineCrudListFilters(definitions);
152
162
  const filterEntries = Object.values(filters);
@@ -179,11 +189,7 @@ function useCrudListFilters(definitions = {}, { labelResolvers = {}, chipLabels
179
189
  ...(isCrudListFilterMultiValue(filter) ? { value: chipValue } : {}),
180
190
  label: normalizeText(customChipLabel?.(chipValue, filter, values))
181
191
  || normalizeText(filter.chipLabel?.(chipValue, filter, values))
182
- || formatCrudListFilterDefaultChipLabel(filter, chipValue, {
183
- resolveAtomicValue(value) {
184
- return resolveAtomicValueLabel(filter, value, normalizedLabelResolvers);
185
- }
186
- })
192
+ || formatDefaultChipLabel(filter, chipValue, normalizedLabelResolvers)
187
193
  });
188
194
  }
189
195
  }
@@ -0,0 +1,91 @@
1
+ const DATE_ONLY_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/u;
2
+
3
+ function parseDateOnlyValue(value = "") {
4
+ const match = DATE_ONLY_PATTERN.exec(String(value || "").trim());
5
+ if (!match) {
6
+ return null;
7
+ }
8
+
9
+ const year = Number(match[1]);
10
+ const month = Number(match[2]);
11
+ const day = Number(match[3]);
12
+ const date = new Date(0);
13
+ date.setHours(12, 0, 0, 0);
14
+ date.setFullYear(year, month - 1, day);
15
+
16
+ if (
17
+ date.getFullYear() !== year ||
18
+ date.getMonth() !== month - 1 ||
19
+ date.getDate() !== day
20
+ ) {
21
+ return null;
22
+ }
23
+
24
+ return date;
25
+ }
26
+
27
+ function formatDateOnlyValue(value = null) {
28
+ const source = Array.isArray(value) ? value[0] : value;
29
+ const date = source instanceof Date
30
+ ? source
31
+ : parseDateOnlyValue(source);
32
+ if (!(date instanceof Date) || Number.isNaN(date.getTime())) {
33
+ return "";
34
+ }
35
+
36
+ const year = date.getFullYear();
37
+ if (year < 0 || year > 9999) {
38
+ return "";
39
+ }
40
+
41
+ return [
42
+ String(year).padStart(4, "0"),
43
+ String(date.getMonth() + 1).padStart(2, "0"),
44
+ String(date.getDate()).padStart(2, "0")
45
+ ].join("-");
46
+ }
47
+
48
+ function formatDateOnlyDisplay(value = "", { locale = undefined } = {}) {
49
+ const date = parseDateOnlyValue(value);
50
+ if (!date) {
51
+ return "";
52
+ }
53
+
54
+ try {
55
+ return new Intl.DateTimeFormat(locale || undefined, {
56
+ year: "numeric",
57
+ month: "short",
58
+ day: "numeric"
59
+ }).format(date);
60
+ } catch {
61
+ return formatDateOnlyValue(date);
62
+ }
63
+ }
64
+
65
+ function formatCrudListDateFilterChipLabel(filter = {}, rawValue, { locale = undefined } = {}) {
66
+ if (filter.type === "date") {
67
+ const dateLabel = formatDateOnlyDisplay(rawValue, { locale }) || String(rawValue || "");
68
+ return dateLabel ? `${filter.label}: ${dateLabel}` : "";
69
+ }
70
+
71
+ if (filter.type !== "dateRange") {
72
+ return "";
73
+ }
74
+
75
+ const from = formatDateOnlyDisplay(rawValue?.from, { locale }) || String(rawValue?.from || "");
76
+ const to = formatDateOnlyDisplay(rawValue?.to, { locale }) || String(rawValue?.to || "");
77
+ if (from && to) {
78
+ return `${filter.label}: ${from} to ${to}`;
79
+ }
80
+ if (from) {
81
+ return `${filter.label}: from ${from}`;
82
+ }
83
+ return to ? `${filter.label}: to ${to}` : "";
84
+ }
85
+
86
+ export {
87
+ parseDateOnlyValue,
88
+ formatDateOnlyValue,
89
+ formatDateOnlyDisplay,
90
+ formatCrudListDateFilterChipLabel
91
+ };
@@ -0,0 +1,92 @@
1
+ import assert from "node:assert/strict";
2
+ import { spawnSync } from "node:child_process";
3
+ import test from "node:test";
4
+ import {
5
+ parseDateOnlyValue,
6
+ formatDateOnlyValue,
7
+ formatDateOnlyDisplay,
8
+ formatCrudListDateFilterChipLabel
9
+ } from "../src/client/support/crudListDateFilterSupport.js";
10
+
11
+ test("date filter picker values round-trip through the YYYY-MM-DD contract", () => {
12
+ const parsed = parseDateOnlyValue("2026-04-18");
13
+
14
+ assert.ok(parsed instanceof Date);
15
+ assert.equal(parsed.getFullYear(), 2026);
16
+ assert.equal(parsed.getMonth(), 3);
17
+ assert.equal(parsed.getDate(), 18);
18
+ assert.equal(formatDateOnlyValue(parsed), "2026-04-18");
19
+ assert.equal(formatDateOnlyValue([new Date(2026, 4, 9)]), "2026-05-09");
20
+ assert.equal(formatDateOnlyValue("2026-11-03"), "2026-11-03");
21
+ });
22
+
23
+ test("date filter parsing rejects impossible calendar dates", () => {
24
+ assert.equal(parseDateOnlyValue("2026-02-29"), null);
25
+ assert.ok(parseDateOnlyValue("2028-02-29") instanceof Date);
26
+ assert.equal(parseDateOnlyValue("04/18/2026"), null);
27
+ assert.equal(parseDateOnlyValue(""), null);
28
+ assert.equal(formatDateOnlyValue(new Date("invalid")), "");
29
+ });
30
+
31
+ test("date filter display and chip labels are locale-aware without exposing Date strings", () => {
32
+ assert.equal(formatDateOnlyDisplay("2026-04-18", { locale: "en-US" }), "Apr 18, 2026");
33
+ assert.equal(formatDateOnlyDisplay("2026-04-18", { locale: "en-GB" }), "18 Apr 2026");
34
+ assert.equal(
35
+ formatCrudListDateFilterChipLabel(
36
+ { type: "date", label: "Submitted" },
37
+ "2026-04-18",
38
+ { locale: "en-US" }
39
+ ),
40
+ "Submitted: Apr 18, 2026"
41
+ );
42
+ assert.equal(
43
+ formatCrudListDateFilterChipLabel(
44
+ { type: "dateRange", label: "Arrival" },
45
+ { from: "2026-05-04", to: "2026-05-10" },
46
+ { locale: "en-US" }
47
+ ),
48
+ "Arrival: May 4, 2026 to May 10, 2026"
49
+ );
50
+ assert.equal(
51
+ formatCrudListDateFilterChipLabel(
52
+ { type: "dateRange", label: "Arrival" },
53
+ { from: "", to: "2026-05-10" },
54
+ { locale: "en-US" }
55
+ ),
56
+ "Arrival: to May 10, 2026"
57
+ );
58
+ });
59
+
60
+ test("date-only conversion does not shift across time zones", () => {
61
+ const supportModuleUrl = new URL(
62
+ "../src/client/support/crudListDateFilterSupport.js",
63
+ import.meta.url
64
+ ).href;
65
+ const script = `
66
+ import { parseDateOnlyValue, formatDateOnlyValue } from ${JSON.stringify(supportModuleUrl)};
67
+ const parsed = parseDateOnlyValue("2026-01-01");
68
+ process.stdout.write(JSON.stringify({
69
+ value: formatDateOnlyValue(parsed),
70
+ year: parsed.getFullYear(),
71
+ month: parsed.getMonth(),
72
+ day: parsed.getDate()
73
+ }));
74
+ `;
75
+
76
+ for (const timezone of ["UTC", "America/Los_Angeles", "Pacific/Kiritimati"]) {
77
+ const result = spawnSync(process.execPath, ["--input-type=module", "--eval", script], {
78
+ encoding: "utf8",
79
+ env: {
80
+ ...process.env,
81
+ TZ: timezone
82
+ }
83
+ });
84
+ assert.equal(result.status, 0, result.stderr);
85
+ assert.deepEqual(JSON.parse(result.stdout), {
86
+ value: "2026-01-01",
87
+ year: 2026,
88
+ month: 0,
89
+ day: 1
90
+ }, timezone);
91
+ }
92
+ });
@@ -0,0 +1,273 @@
1
+ import assert from "node:assert/strict";
2
+ import { spawn } from "node:child_process";
3
+ import net from "node:net";
4
+ import path from "node:path";
5
+ import process from "node:process";
6
+ import test from "node:test";
7
+ import { fileURLToPath } from "node:url";
8
+ import { chromium } from "@playwright/test";
9
+
10
+ const TEST_DIRECTORY = path.dirname(fileURLToPath(import.meta.url));
11
+ const REPOSITORY_ROOT = path.resolve(TEST_DIRECTORY, "../../..");
12
+ const PACKAGE_ROOT = path.resolve(TEST_DIRECTORY, "..");
13
+ const FIXTURE_ROOT = path.join(PACKAGE_ROOT, "fixtures", "crud-list-date-filters");
14
+ const VITE_CLI = path.join(REPOSITORY_ROOT, "node_modules", "vite", "bin", "vite.js");
15
+ const RUN_BROWSER_TEST = process.env.JSKIT_USERS_WEB_DATE_FILTER_VISUAL_INTEGRATION === "1";
16
+ const VIEWPORT_WIDTHS = Object.freeze([375, 768, 1280, 1440]);
17
+ const QUERY = "?status=active&submittedOn=2026-04-18&arrivalDate=2026-05-04..2026-05-10";
18
+
19
+ function startCapturedProcess(command, args, { cwd } = {}) {
20
+ const child = spawn(command, args, {
21
+ cwd,
22
+ stdio: ["ignore", "pipe", "pipe"],
23
+ env: {
24
+ ...process.env,
25
+ NO_COLOR: "1",
26
+ FORCE_COLOR: "0"
27
+ }
28
+ });
29
+ let output = "";
30
+
31
+ child.stdout.on("data", (chunk) => {
32
+ output += chunk.toString("utf8");
33
+ });
34
+ child.stderr.on("data", (chunk) => {
35
+ output += chunk.toString("utf8");
36
+ });
37
+
38
+ function waitFor(pattern, timeout = 30_000) {
39
+ return new Promise((resolve, reject) => {
40
+ const startedAt = Date.now();
41
+ const timer = setInterval(() => {
42
+ if (pattern.test(output)) {
43
+ clearInterval(timer);
44
+ resolve();
45
+ return;
46
+ }
47
+ if (child.exitCode !== null) {
48
+ clearInterval(timer);
49
+ reject(new Error(`Vite exited before ${pattern}.\n${output}`));
50
+ return;
51
+ }
52
+ if (Date.now() - startedAt >= timeout) {
53
+ clearInterval(timer);
54
+ reject(new Error(`Timed out waiting for ${pattern}.\n${output}`));
55
+ }
56
+ }, 50);
57
+ });
58
+ }
59
+
60
+ return {
61
+ child,
62
+ readOutput: () => output,
63
+ waitFor
64
+ };
65
+ }
66
+
67
+ async function stopProcess(runtime) {
68
+ const child = runtime?.child;
69
+ if (!child || child.exitCode !== null || child.signalCode !== null) {
70
+ return;
71
+ }
72
+
73
+ await new Promise((resolve) => {
74
+ const forceTimer = setTimeout(() => child.kill("SIGKILL"), 5_000);
75
+ child.once("exit", () => {
76
+ clearTimeout(forceTimer);
77
+ resolve();
78
+ });
79
+ child.kill("SIGTERM");
80
+ });
81
+ }
82
+
83
+ async function reservePort() {
84
+ const server = net.createServer();
85
+ await new Promise((resolve, reject) => {
86
+ server.once("error", reject);
87
+ server.listen(0, "127.0.0.1", resolve);
88
+ });
89
+ const address = server.address();
90
+ assert.ok(address && typeof address === "object");
91
+ await new Promise((resolve, reject) => {
92
+ server.close((error) => error ? reject(error) : resolve());
93
+ });
94
+ return address.port;
95
+ }
96
+
97
+ async function readContract(page) {
98
+ return JSON.parse(await page.getByTestId("date-filter-contract").textContent());
99
+ }
100
+
101
+ async function openCompactFilters(page) {
102
+ const openButton = page.getByRole("button", { name: /^Filters/u });
103
+ if (await openButton.isVisible()) {
104
+ await openButton.click();
105
+ await page.getByRole("dialog").waitFor({ state: "visible" });
106
+ }
107
+ }
108
+
109
+ async function assertResponsiveDateControls(page, width) {
110
+ await page.setViewportSize({ width, height: 900 });
111
+ await page.goto(`http://127.0.0.1:${page.__jskitPort}/${QUERY}`, {
112
+ waitUntil: "networkidle"
113
+ });
114
+ await openCompactFilters(page);
115
+
116
+ for (const name of ["Submitted date", "Arrival from", "Arrival to"]) {
117
+ const field = page.getByLabel(name, { exact: true });
118
+ await field.waitFor({ state: "visible" });
119
+ const box = await field.locator("xpath=ancestor::*[contains(@class, 'v-input')][1]").boundingBox();
120
+ assert.ok(box, `${name} is missing a visible field at ${width}px.`);
121
+ assert.ok(box.width >= 220, `${name} is only ${box.width}px wide at ${width}px.`);
122
+ assert.ok(box.x >= 0 && box.x + box.width <= width, `${name} overflows at ${width}px.`);
123
+ }
124
+
125
+ const layout = await page.evaluate(() => ({
126
+ clientWidth: document.documentElement.clientWidth,
127
+ scrollWidth: document.documentElement.scrollWidth,
128
+ clippedFields: Array.from(document.querySelectorAll(".crud-list-date-filter-control .v-field"))
129
+ .filter((field) => field.scrollWidth > field.clientWidth + 1)
130
+ .length,
131
+ visibleCalendarIcons: Array.from(document.querySelectorAll(
132
+ ".crud-list-date-filter-control .v-field__append-inner"
133
+ )).filter((icon) => {
134
+ const rect = icon.getBoundingClientRect();
135
+ return rect.width > 0 && rect.right <= window.innerWidth;
136
+ }).length
137
+ }));
138
+ assert.equal(layout.scrollWidth, layout.clientWidth, `Horizontal overflow at ${width}px.`);
139
+ assert.equal(layout.clippedFields, 0, `A date field is clipped at ${width}px.`);
140
+ assert.equal(layout.visibleCalendarIcons, 3, `Calendar affordance missing at ${width}px.`);
141
+
142
+ const screenshot = await page.screenshot({ fullPage: true });
143
+ assert.ok(screenshot.length > 5_000, `Visual evidence was empty at ${width}px.`);
144
+ }
145
+
146
+ async function selectPickerDayWithKeyboard(picker, day) {
147
+ const dayButton = picker
148
+ .locator(".v-date-picker-month__day-btn")
149
+ .filter({ hasText: new RegExp(`^${day}$`, "u") })
150
+ .first();
151
+ await dayButton.focus();
152
+ await dayButton.press("Space");
153
+ }
154
+
155
+ test("CRUD date filters render and operate without clipping across responsive layouts", {
156
+ skip: RUN_BROWSER_TEST
157
+ ? false
158
+ : "set JSKIT_USERS_WEB_DATE_FILTER_VISUAL_INTEGRATION=1 to run the browser visual regression",
159
+ timeout: 180_000
160
+ }, async () => {
161
+ const port = await reservePort();
162
+ const viteRuntime = startCapturedProcess(process.execPath, [
163
+ VITE_CLI,
164
+ "--config",
165
+ path.join(FIXTURE_ROOT, "vite.config.mjs"),
166
+ "--port",
167
+ String(port),
168
+ "--clearScreen",
169
+ "false"
170
+ ], { cwd: FIXTURE_ROOT });
171
+ let browser = null;
172
+
173
+ try {
174
+ await viteRuntime.waitFor(new RegExp(`http://127\\.0\\.0\\.1:${port}/`, "u"));
175
+ const chromiumExecutablePath = String(
176
+ process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH || ""
177
+ ).trim();
178
+ browser = await chromium.launch({
179
+ headless: true,
180
+ ...(chromiumExecutablePath ? { executablePath: chromiumExecutablePath } : {})
181
+ });
182
+ const context = await browser.newContext({ locale: "en-US" });
183
+ const page = await context.newPage();
184
+ page.__jskitPort = port;
185
+
186
+ for (const width of VIEWPORT_WIDTHS) {
187
+ await assertResponsiveDateControls(page, width);
188
+ }
189
+
190
+ await page.setViewportSize({ width: 1280, height: 900 });
191
+ await page.goto(`http://127.0.0.1:${port}/${QUERY}`, { waitUntil: "networkidle" });
192
+ assert.deepEqual(await readContract(page), {
193
+ values: {
194
+ submittedOn: "2026-04-18",
195
+ arrivalDate: { from: "2026-05-04", to: "2026-05-10" }
196
+ },
197
+ query: {
198
+ submittedOn: "2026-04-18",
199
+ arrivalDate: "2026-05-04..2026-05-10"
200
+ }
201
+ });
202
+ await page.getByText("Submitted date: Apr 18, 2026", { exact: true }).waitFor();
203
+ await page.getByText("Arrival: May 4, 2026 to May 10, 2026", { exact: true }).waitFor();
204
+
205
+ const submittedField = page.getByLabel("Submitted date", { exact: true });
206
+ await submittedField.focus();
207
+ await submittedField.press("Enter");
208
+ const submittedPicker = page.getByRole("dialog", { name: "Submitted date calendar" });
209
+ await submittedPicker.waitFor({ state: "visible" });
210
+ await selectPickerDayWithKeyboard(submittedPicker, 21);
211
+ assert.equal((await readContract(page)).values.submittedOn, "2026-04-21");
212
+ assert.equal((await readContract(page)).query.submittedOn, "2026-04-21");
213
+ assert.equal(await submittedField.evaluate((element) => element === document.activeElement), true);
214
+
215
+ const arrivalToField = page.getByLabel("Arrival to", { exact: true });
216
+ await arrivalToField.focus();
217
+ await arrivalToField.press("ArrowDown");
218
+ const arrivalToPicker = page.getByRole("dialog", { name: "Arrival to calendar" });
219
+ await arrivalToPicker.waitFor({ state: "visible" });
220
+ await selectPickerDayWithKeyboard(arrivalToPicker, 12);
221
+ assert.deepEqual((await readContract(page)).values.arrivalDate, {
222
+ from: "2026-05-04",
223
+ to: "2026-05-12"
224
+ });
225
+ assert.equal((await readContract(page)).query.arrivalDate, "2026-05-04..2026-05-12");
226
+
227
+ await arrivalToField.focus();
228
+ await arrivalToField.press("Enter");
229
+ const clearButton = page
230
+ .getByRole("dialog", { name: "Arrival to calendar" })
231
+ .getByRole("button", { name: "Clear", exact: true });
232
+ await clearButton.focus();
233
+ await clearButton.press("Space");
234
+ assert.deepEqual((await readContract(page)).values.arrivalDate, {
235
+ from: "2026-05-04",
236
+ to: ""
237
+ });
238
+ assert.equal((await readContract(page)).query.arrivalDate, "2026-05-04..");
239
+ assert.equal(await arrivalToField.evaluate((element) => element === document.activeElement), true);
240
+
241
+ const arrivalFromField = page.getByLabel("Arrival from", { exact: true });
242
+ await arrivalFromField.focus();
243
+ await arrivalFromField.press(" ");
244
+ await page.getByRole("dialog", { name: "Arrival from calendar" }).waitFor();
245
+ await page.keyboard.press("Escape");
246
+ assert.equal(await arrivalFromField.evaluate((element) => element === document.activeElement), true);
247
+
248
+ const clearAll = page.getByRole("button", { name: "Clear all", exact: true });
249
+ await clearAll.focus();
250
+ await clearAll.press("Space");
251
+ assert.deepEqual((await readContract(page)).values, {
252
+ submittedOn: "",
253
+ arrivalDate: { from: "", to: "" }
254
+ });
255
+
256
+ await page.setViewportSize({ width: 375, height: 812 });
257
+ await page.reload({ waitUntil: "networkidle" });
258
+ const openFilters = page.getByRole("button", { name: /^Filters/u });
259
+ const openButtonBox = await openFilters.boundingBox();
260
+ assert.ok(openButtonBox && openButtonBox.height >= 48);
261
+ await openFilters.click();
262
+ const mobileSheet = page.getByRole("dialog");
263
+ await mobileSheet.waitFor({ state: "visible" });
264
+ const mobileSheetBox = await mobileSheet.boundingBox();
265
+ assert.ok(mobileSheetBox && mobileSheetBox.width <= 375);
266
+ await page.getByLabel("Arrival from", { exact: true }).waitFor({ state: "visible" });
267
+ } finally {
268
+ if (browser) {
269
+ await browser.close();
270
+ }
271
+ await stopProcess(viteRuntime);
272
+ }
273
+ });
@@ -3,6 +3,7 @@ import { readFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import test from "node:test";
5
5
  import { fileURLToPath } from "node:url";
6
+ import { compileScript, compileTemplate, parse } from "@vue/compiler-sfc";
6
7
 
7
8
  const TEST_DIRECTORY = path.dirname(fileURLToPath(import.meta.url));
8
9
  const PACKAGE_DIR = path.resolve(TEST_DIRECTORY, "..");
@@ -40,6 +41,50 @@ test("CrudListFilterSurface provides adaptive controls without owning server sem
40
41
  assert.match(source, /clearFilters/);
41
42
  assert.match(source, /v-dialog/);
42
43
  assert.match(source, /min-height:\s*48px/);
44
+ assert.match(source, /CrudListDateFilterControl/);
45
+ assert.match(source, /crud-list-filter-surface__range--date/);
46
+ assert.match(source, /grid-column:\s*span 2/);
47
+ assert.doesNotMatch(source, /type="date"/);
43
48
  assert.doesNotMatch(source, /useCrudList\(/);
44
49
  assert.doesNotMatch(source, /apiSuffix|server|repository/);
45
50
  });
51
+
52
+ test("CrudListDateFilterControl composes stable Vuetify picker components accessibly", async () => {
53
+ const componentPath = path.join(
54
+ PACKAGE_DIR,
55
+ "src",
56
+ "client",
57
+ "components",
58
+ "CrudListDateFilterControl.vue"
59
+ );
60
+ const source = await readFile(componentPath, "utf8");
61
+
62
+ assert.match(source, /<v-menu/);
63
+ assert.match(source, /<v-text-field/);
64
+ assert.match(source, /<v-date-picker/);
65
+ assert.match(source, /append-inner-icon="\$calendar"/);
66
+ assert.match(source, /:aria-label="label"/);
67
+ assert.match(source, /aria-haspopup="dialog"/);
68
+ assert.match(source, /v-bind="\{ \.\.\.attrs, \.\.\.activatorProps \}"/);
69
+ assert.match(source, /\["Enter", " ", "ArrowDown"\]/);
70
+ assert.match(source, /role="dialog"/);
71
+ assert.match(source, />\s*Clear\s*</u);
72
+ assert.match(source, />\s*Close\s*</u);
73
+ assert.doesNotMatch(source, /VDateInput|vuetify\/labs|type="date"/);
74
+
75
+ const { descriptor, errors: parseErrors } = parse(source, { filename: componentPath });
76
+ assert.deepEqual(parseErrors, []);
77
+ const script = compileScript(descriptor, {
78
+ id: "crud-list-date-filter-control"
79
+ });
80
+ const compiled = compileTemplate({
81
+ id: "crud-list-date-filter-control",
82
+ filename: componentPath,
83
+ source: descriptor.template.content,
84
+ scoped: descriptor.styles.some((style) => style.scoped),
85
+ compilerOptions: {
86
+ bindingMetadata: script.bindings
87
+ }
88
+ });
89
+ assert.deepEqual(compiled.errors, []);
90
+ });
@@ -1,5 +1,6 @@
1
1
  import test from "node:test";
2
2
  import assert from "node:assert/strict";
3
+ import { formatDateOnlyDisplay } from "../src/client/support/crudListDateFilterSupport.js";
3
4
 
4
5
  test("useCrudListFilters manages values, query params, chips, and presets", async () => {
5
6
  const { useCrudListFilters } = await import("@jskit-ai/users-web/client/composables/useCrudListFilters");
@@ -61,7 +62,7 @@ test("useCrudListFilters manages values, query params, chips, and presets", asyn
61
62
  "Staff",
62
63
  "Status: Active",
63
64
  "Supplier: Pollen Partners",
64
- "Arrival Date: from 2026-04-01"
65
+ `Arrival Date: from ${formatDateOnlyDisplay("2026-04-01")}`
65
66
  ]
66
67
  );
67
68
 
@@ -209,3 +210,50 @@ test("useCrudListFilters hydrates single-key range query params into structured
209
210
  max: "18"
210
211
  });
211
212
  });
213
+
214
+ test("useCrudListFilters preserves scalar, complete, partial, and cleared date contracts", async () => {
215
+ const { useCrudListFilters } = await import("@jskit-ai/users-web/client/composables/useCrudListFilters");
216
+ const filters = useCrudListFilters({
217
+ submittedOn: {
218
+ type: "date",
219
+ label: "Submitted"
220
+ },
221
+ arrivalDate: {
222
+ type: "dateRange",
223
+ label: "Arrival"
224
+ }
225
+ });
226
+
227
+ filters.queryParams.submittedOn.value = "2026-04-18";
228
+ filters.queryParams.arrivalDate.value = "2026-05-04..2026-05-10";
229
+
230
+ assert.equal(filters.values.submittedOn, "2026-04-18");
231
+ assert.deepEqual(filters.values.arrivalDate, {
232
+ from: "2026-05-04",
233
+ to: "2026-05-10"
234
+ });
235
+ assert.equal(filters.queryParams.submittedOn.value, "2026-04-18");
236
+ assert.equal(filters.queryParams.arrivalDate.value, "2026-05-04..2026-05-10");
237
+ assert.deepEqual(
238
+ filters.activeChips.value.map((chip) => chip.label),
239
+ [
240
+ `Submitted: ${formatDateOnlyDisplay("2026-04-18")}`,
241
+ `Arrival: ${formatDateOnlyDisplay("2026-05-04")} to ${formatDateOnlyDisplay("2026-05-10")}`
242
+ ]
243
+ );
244
+
245
+ filters.values.arrivalDate.to = "";
246
+ assert.equal(filters.queryParams.arrivalDate.value, "2026-05-04..");
247
+ assert.equal(
248
+ filters.activeChips.value.at(-1).label,
249
+ `Arrival: from ${formatDateOnlyDisplay("2026-05-04")}`
250
+ );
251
+
252
+ filters.clearFilter("submittedOn");
253
+ assert.equal(filters.values.submittedOn, "");
254
+ assert.equal(filters.queryParams.submittedOn.value, "");
255
+
256
+ filters.clearFilters();
257
+ assert.deepEqual(filters.values.arrivalDate, { from: "", to: "" });
258
+ assert.equal(filters.hasActiveFilters.value, false);
259
+ });