@descope-ui/descope-month-day-field-picker 3.13.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/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@descope-ui/descope-month-day-field-picker",
3
+ "version": "3.13.0",
4
+ "files": [
5
+ "src",
6
+ "stories"
7
+ ],
8
+ "exports": {
9
+ ".": {
10
+ "import": "./src/component/index.js"
11
+ },
12
+ "./theme": {
13
+ "import": "./src/theme.js"
14
+ },
15
+ "./class": {
16
+ "import": "./src/component/MonthDayFieldPickerClass.js"
17
+ }
18
+ },
19
+ "devDependencies": {
20
+ "@playwright/test": "1.58.2",
21
+ "e2e-utils": "3.13.0",
22
+ "test-drivers": "3.13.0"
23
+ },
24
+ "dependencies": {
25
+ "@descope-ui/common": "3.13.0",
26
+ "@descope-ui/theme-globals": "3.13.0",
27
+ "@descope-ui/theme-input-wrapper": "3.13.0",
28
+ "@descope-ui/descope-combo-box": "3.13.0"
29
+ },
30
+ "publishConfig": {
31
+ "link-workspace-packages": false
32
+ },
33
+ "scripts": {
34
+ "test": "echo 'No tests defined' && exit 0",
35
+ "test:e2e": "echo 'No e2e tests defined' && exit 0"
36
+ }
37
+ }
@@ -0,0 +1,354 @@
1
+ import { createBaseInputClass } from '@descope-ui/common/base-classes';
2
+ import { compose } from '@descope-ui/common/utils';
3
+ import {
4
+ forwardAttrs,
5
+ getComponentName,
6
+ injectStyle,
7
+ } from '@descope-ui/common/components-helpers';
8
+ import {
9
+ componentNameValidationMixin,
10
+ createStyleMixin,
11
+ draggableMixin,
12
+ } from '@descope-ui/common/components-mixins';
13
+
14
+ import { DAYS_PER_MONTH, months as DEFAULT_MONTHS } from './consts';
15
+
16
+ export const componentName = getComponentName('month-day-field-picker');
17
+
18
+ const observedAttrs = [
19
+ 'initial-value',
20
+ 'picker-months',
21
+ 'st-host-direction',
22
+ 'disabled',
23
+ 'full-width',
24
+ ];
25
+
26
+ const BaseInputClass = createBaseInputClass({
27
+ componentName,
28
+ baseSelector: 'div',
29
+ });
30
+
31
+ const isValidMonthNamesArr = (arr) =>
32
+ Array.isArray(arr) && arr.length === 12 && arr.filter(Boolean).length === 12;
33
+
34
+ const ensureMonthNamesArr = (attrVal) => {
35
+ const arr = attrVal?.split(',');
36
+ return isValidMonthNamesArr(arr) ? arr : DEFAULT_MONTHS;
37
+ };
38
+
39
+ // Build descope-combo-box items via DOM (textContent / setAttribute) rather than an
40
+ // innerHTML template. picker-months is a user-facing attribute (localized strings,
41
+ // admin-configured copy) so labels must never be parsed as HTML.
42
+ const buildOptions = (entries, parent) => {
43
+ const fragment = document.createDocumentFragment();
44
+ entries.forEach(({ id, name }) => {
45
+ const item = document.createElement('div');
46
+ item.className = 'combo-box-item';
47
+ item.setAttribute('data-id', String(id));
48
+ item.setAttribute('data-name', name);
49
+ item.textContent = name;
50
+ fragment.appendChild(item);
51
+ });
52
+ parent.replaceChildren(fragment);
53
+ };
54
+
55
+ const monthEntries = (names) =>
56
+ names.map((name, idx) => ({ id: idx + 1, name }));
57
+
58
+ const dayEntries = (count) =>
59
+ Array.from({ length: count }, (_, i) => ({ id: i + 1, name: String(i + 1) }));
60
+
61
+ class RawMonthDayPicker extends BaseInputClass {
62
+ static get observedAttributes() {
63
+ return [].concat(BaseInputClass.observedAttributes || [], observedAttrs);
64
+ }
65
+
66
+ // 1..12 or null
67
+ #month = null;
68
+
69
+ // 1..N (N depends on month) or null
70
+ #day = null;
71
+
72
+ constructor() {
73
+ super();
74
+
75
+ this.attachShadow({ mode: 'open' }).innerHTML = `
76
+ <div class="picker">
77
+ <div class="inputs-row">
78
+ <descope-combo-box
79
+ class="month-input"
80
+ bordered="true"
81
+ label-type="static"
82
+ allow-custom-value="false"
83
+ full-width="true"
84
+ item-label-path="data-name"
85
+ item-value-path="data-id"
86
+ ></descope-combo-box>
87
+ <descope-combo-box
88
+ class="day-input"
89
+ bordered="true"
90
+ label-type="static"
91
+ allow-custom-value="false"
92
+ full-width="true"
93
+ item-label-path="data-name"
94
+ item-value-path="data-id"
95
+ ></descope-combo-box>
96
+ </div>
97
+ </div>
98
+ `;
99
+
100
+ injectStyle(
101
+ `
102
+ :host {
103
+ display: inline-block;
104
+ box-sizing: border-box;
105
+ max-width: 100%;
106
+ user-select: none;
107
+ -webkit-user-select: none;
108
+ }
109
+
110
+ .inputs-row {
111
+ display: flex;
112
+ }
113
+
114
+ .inputs-row > descope-combo-box {
115
+ flex: 1;
116
+ min-width: 0;
117
+ }
118
+ `,
119
+ this,
120
+ );
121
+
122
+ // Bind refs here so attributeChangedCallback (which fires before init) can
123
+ // render into the combos instead of bailing on unbound refs and being redone.
124
+ this.monthInput = this.shadowRoot.querySelector('.month-input');
125
+ this.dayInput = this.shadowRoot.querySelector('.day-input');
126
+ }
127
+
128
+ // ---- public API ----
129
+
130
+ get month() {
131
+ return this.#month;
132
+ }
133
+
134
+ set month(val) {
135
+ const n = Number(val);
136
+ if (!Number.isInteger(n) || n < 1 || n > 12) {
137
+ this.#month = null;
138
+ } else {
139
+ this.#month = n;
140
+ }
141
+ // If the previously selected day no longer exists in the newly selected month
142
+ // (e.g. April 30 → February), reset to day 1. Days that still fit are kept
143
+ // (e.g. April 30 → January keeps 30).
144
+ if (
145
+ this.#day != null &&
146
+ this.#month != null &&
147
+ this.#day > DAYS_PER_MONTH[this.#month - 1]
148
+ ) {
149
+ this.#day = 1;
150
+ }
151
+ this.#syncMonthInput();
152
+ this.#renderDayOptions();
153
+ this.#syncDayInput();
154
+ this.#dispatchChange();
155
+ }
156
+
157
+ get day() {
158
+ return this.#day;
159
+ }
160
+
161
+ set day(val) {
162
+ const n = Number(val);
163
+ if (!Number.isInteger(n) || n < 1) {
164
+ this.#day = null;
165
+ } else if (this.#month != null && n > DAYS_PER_MONTH[this.#month - 1]) {
166
+ this.#day = null;
167
+ } else {
168
+ this.#day = n;
169
+ }
170
+ this.#syncDayInput();
171
+ this.#dispatchChange();
172
+ }
173
+
174
+ /**
175
+ * Sets both month and day from a string `"MM/DD"`. Empty / null / undefined resets to
176
+ * today's date (current real-world month + current day). Other formats are silently ignored.
177
+ */
178
+ set value(val) {
179
+ if (!val) {
180
+ this.#defaultToToday();
181
+ return;
182
+ }
183
+ const match = /^(0?[1-9]|1[0-2])\D(0?[1-9]|[12][0-9]|3[01])$/.exec(
184
+ String(val).trim(),
185
+ );
186
+ if (!match) return;
187
+ const m = Number(match[1]);
188
+ const d = Number(match[2]);
189
+ this.month = m;
190
+ // Re-assign day after month so the DAYS_PER_MONTH ceiling check uses the new month.
191
+ this.day = d;
192
+ }
193
+
194
+ #defaultToToday() {
195
+ const now = new Date();
196
+ this.month = now.getMonth() + 1;
197
+ this.day = now.getDate();
198
+ }
199
+
200
+ /** Returns `"MM/DD"` when both values are set; empty string otherwise. */
201
+ get value() {
202
+ if (this.#month == null || this.#day == null) return '';
203
+ return `${String(this.#month).padStart(2, '0')}/${String(this.#day).padStart(2, '0')}`;
204
+ }
205
+
206
+ // ---- lifecycle ----
207
+
208
+ init() {
209
+ super.init?.();
210
+
211
+ this.#initMonthInput();
212
+ this.#initDayInput();
213
+
214
+ // If we have neither initial-value nor an explicit month, default to today's date
215
+ // (current real-world month + current day). The month/day setters paint the combos.
216
+ const initial = this.getAttribute('initial-value');
217
+ if (initial) {
218
+ this.value = initial;
219
+ } else if (this.#month == null) {
220
+ this.#defaultToToday();
221
+ }
222
+
223
+ forwardAttrs(this, this.monthInput, {
224
+ includeAttrs: ['disabled', 'size', 'st-host-direction'],
225
+ });
226
+ forwardAttrs(this, this.dayInput, {
227
+ includeAttrs: ['disabled', 'size', 'st-host-direction'],
228
+ });
229
+ }
230
+
231
+ attributeChangedCallback(name, oldVal, newVal) {
232
+ super.attributeChangedCallback?.(name, oldVal, newVal);
233
+ if (oldVal === newVal) return;
234
+
235
+ if (name === 'initial-value') {
236
+ this.value = newVal;
237
+ } else if (name === 'picker-months') {
238
+ this.#renderMonthOptions();
239
+ }
240
+ }
241
+
242
+ // ---- internals ----
243
+
244
+ get #monthNames() {
245
+ return ensureMonthNamesArr(this.getAttribute('picker-months'));
246
+ }
247
+
248
+ #initMonthInput() {
249
+ this.#renderMonthOptions();
250
+ this.monthInput.addEventListener('input', this.#onMonthChange.bind(this));
251
+ }
252
+
253
+ #initDayInput() {
254
+ // Day options are populated lazily — they depend on the selected month.
255
+ this.dayInput.addEventListener('input', this.#onDayChange.bind(this));
256
+ }
257
+
258
+ #renderMonthOptions() {
259
+ buildOptions(monthEntries(this.#monthNames), this.monthInput);
260
+ this.#syncMonthInput();
261
+ }
262
+
263
+ #renderDayOptions() {
264
+ const count = DAYS_PER_MONTH[this.#month - 1] ?? 0;
265
+ buildOptions(dayEntries(count), this.dayInput);
266
+ }
267
+
268
+ #syncMonthInput() {
269
+ if (!this.monthInput) return;
270
+ // Defer one tick: descope-combo-box indexes its options via MutationObserver
271
+ // (microtask). Setting `.value` synchronously right after appending options
272
+ // silently fails to select an item, leaving the combo empty. Calendar uses
273
+ // the same setTimeout workaround for its year input.
274
+ setTimeout(() => {
275
+ if (!this.monthInput) return;
276
+ this.monthInput.value = this.#month != null ? String(this.#month) : '';
277
+ });
278
+ }
279
+
280
+ #syncDayInput() {
281
+ // Same microtask deferral as #syncMonthInput: the combo-box indexes its options
282
+ // via MutationObserver, so setting `.value` synchronously after rendering options
283
+ // silently no-ops.
284
+ setTimeout(() => {
285
+ if (!this.dayInput) return;
286
+ this.dayInput.value = this.#day !== null ? String(this.#day) : '';
287
+ });
288
+ }
289
+
290
+ #onMonthChange(e) {
291
+ const raw = e.target?.value;
292
+ const next = raw ? Number(raw) : null;
293
+ if (next === this.#month) return;
294
+ this.month = next;
295
+ }
296
+
297
+ #onDayChange(e) {
298
+ const raw = e.target?.value;
299
+ const next = raw ? Number(raw) : null;
300
+ if (next === this.#day) return;
301
+ this.day = next;
302
+ }
303
+
304
+ // Emitted on every month/day mutation (user or programmatic) so a host can observe
305
+ // the picker's value/validity — e.g. to drive an external submit button's enabled
306
+ // state. The host (descope-month-day-field) owns the Done/Cancel buttons.
307
+ #dispatchChange() {
308
+ this.dispatchEvent(
309
+ new CustomEvent('change', {
310
+ detail: { month: this.#month, day: this.#day },
311
+ bubbles: true,
312
+ composed: true,
313
+ }),
314
+ );
315
+ }
316
+
317
+ // The picker is popover content, not a form field, so it has no validation semantics.
318
+ // Returning an empty validity object satisfies inputValidationMixin (the base class
319
+ // default returns undefined and crashes the mixin's getErrorMessage destructure).
320
+ getValidity() {
321
+ return {};
322
+ }
323
+ }
324
+
325
+ const { picker, monthInput, dayInput, inputsRow } = {
326
+ picker: { selector: () => '.picker' },
327
+ monthInput: { selector: () => '.month-input' },
328
+ dayInput: { selector: () => '.day-input' },
329
+ inputsRow: { selector: () => '.inputs-row' },
330
+ };
331
+
332
+ export const MonthDayFieldPickerClass = compose(
333
+ createStyleMixin({
334
+ componentNameOverride: getComponentName('input-wrapper'),
335
+ }),
336
+ createStyleMixin({
337
+ mappings: {
338
+ fontSize: {},
339
+ fontFamily: {},
340
+ hostWidth: { selector: () => ':host', property: 'width' },
341
+ hostDirection: { property: 'direction' },
342
+
343
+ pickerPadding: { ...picker, property: 'padding' },
344
+
345
+ inputsRowGap: { ...inputsRow, property: 'gap' },
346
+ inputWidth: [
347
+ { ...monthInput, property: 'width' },
348
+ { ...dayInput, property: 'width' },
349
+ ],
350
+ },
351
+ }),
352
+ draggableMixin,
353
+ componentNameValidationMixin,
354
+ )(RawMonthDayPicker);
@@ -0,0 +1,18 @@
1
+ // February shows 29 days even though Feb 29 only exists in leap years —
2
+ // the picker intentionally has no year, so Feb 29 is unconditionally allowed.
3
+ export const DAYS_PER_MONTH = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
4
+
5
+ export const months = [
6
+ 'January',
7
+ 'February',
8
+ 'March',
9
+ 'April',
10
+ 'May',
11
+ 'June',
12
+ 'July',
13
+ 'August',
14
+ 'September',
15
+ 'October',
16
+ 'November',
17
+ 'December',
18
+ ];
@@ -0,0 +1,10 @@
1
+ import '@descope-ui/descope-combo-box';
2
+
3
+ import {
4
+ componentName,
5
+ MonthDayFieldPickerClass,
6
+ } from './MonthDayFieldPickerClass';
7
+
8
+ customElements.define(componentName, MonthDayFieldPickerClass);
9
+
10
+ export { MonthDayFieldPickerClass, componentName };
package/src/theme.js ADDED
@@ -0,0 +1,25 @@
1
+ import { refs } from '@descope-ui/theme-input-wrapper';
2
+ import { MonthDayFieldPickerClass } from './component/MonthDayFieldPickerClass';
3
+
4
+ const vars = MonthDayFieldPickerClass.cssVarList;
5
+
6
+ const monthDayFieldPicker = {
7
+ [vars.fontFamily]: refs.fontFamily,
8
+ [vars.fontSize]: refs.fontSize,
9
+ [vars.hostDirection]: refs.direction,
10
+
11
+ [vars.inputWidth]: '130px',
12
+
13
+ // Calendar-style padding around the content. Combos use flex:1 (see class CSS)
14
+ // to share the remaining horizontal space, landing at ~130px each at default
15
+ // font size — same breathing-room calendar's two combos have.
16
+ [vars.pickerPadding]: '1em 1em',
17
+ [vars.inputsRowGap]: '0.5em',
18
+
19
+ _fullWidth: {
20
+ [vars.hostWidth]: '100%',
21
+ },
22
+ };
23
+
24
+ export default monthDayFieldPicker;
25
+ export { vars };
@@ -0,0 +1,61 @@
1
+ import { componentName } from '../src/component';
2
+ import {
3
+ sizeControl,
4
+ fullWidthControl,
5
+ directionControl,
6
+ disabledControl,
7
+ } from '@descope-ui/common/sb-controls';
8
+
9
+ const Template = ({
10
+ size,
11
+ direction,
12
+ disabled,
13
+ 'full-width': fullWidth,
14
+ 'initial-value': initialValue,
15
+ 'picker-months': pickerMonths,
16
+ }) => `
17
+ <descope-month-day-field-picker
18
+ size="${size}"
19
+ disabled="${disabled || false}"
20
+ initial-value="${initialValue || ''}"
21
+ full-width="${fullWidth || false}"
22
+ st-host-direction="${direction ?? ''}"
23
+ picker-months="${pickerMonths || ''}"
24
+ ></descope-month-day-field-picker>
25
+ `;
26
+
27
+ export default {
28
+ component: componentName,
29
+ title: 'descope-month-day-field-picker',
30
+ decorators: [
31
+ (story) => {
32
+ setTimeout(() => {
33
+ const picker = document.querySelector('descope-month-day-field-picker');
34
+ picker.addEventListener('change', (e) =>
35
+ console.log('change', e.detail),
36
+ );
37
+ });
38
+ return story();
39
+ },
40
+ ],
41
+ argTypes: {
42
+ ...sizeControl,
43
+ ...fullWidthControl,
44
+ ...disabledControl,
45
+ ...directionControl,
46
+ 'initial-value': {
47
+ name: 'Initial Value',
48
+ control: { type: 'text' },
49
+ },
50
+ 'picker-months': {
51
+ name: 'Custom Months',
52
+ control: { type: 'text' },
53
+ },
54
+ },
55
+ };
56
+
57
+ export const Default = Template.bind({});
58
+
59
+ Default.args = {
60
+ size: 'md',
61
+ };