View | Details | Raw Unified | Return to bug 33886
Collapse All | Expand All

(-)a/installer/data/mysql/atomicupdate/bug_33886.pl (+26 lines)
Line 0 Link Here
1
use Modern::Perl;
2
use Koha::Installer::Output qw(say_warning say_success say_info);
3
4
return {
5
    bug_number  => "33886",
6
    description => "Add DateInputStyle system preference",
7
    up          => sub {
8
        my ($args) = @_;
9
        my ( $dbh, $out ) = @$args{qw(dbh out)};
10
11
        $dbh->do(
12
            q{
13
            INSERT IGNORE INTO systempreferences ( variable, value, options, explanation, type )
14
            VALUES (
15
                'DateInputStyle',
16
                'single',
17
                'single|split',
18
                'Controls how date input fields are displayed. "single" uses the standard flatpickr calendar widget with maskito input masking. "split" shows three separate inputs for day, month, and year (order follows the dateformat preference) with an optional calendar button; does not apply to time, datetime, or booking date range fields.',
19
                'Choice'
20
            )
21
        }
22
        );
23
24
        say_success( $out, "Added new system preference 'DateInputStyle'" );
25
    },
26
};
(-)a/koha-tmpl/intranet-tmpl/prog/css/src/_flatpickr.scss (-1 / +3 lines)
Lines 1-5 Link Here
1
@import "flatpickr_vars";
1
@import "flatpickr_vars";
2
2
3
$flatpickr-calendar-icon: url( "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23369' class='bi bi-calendar3' viewBox='0 0 32 32'%3E%3Cg stroke-width='1.333'%3E%3Cpath d='M25 4H7a3 3 0 0 0-3 3v18a3 3 0 0 0 3 3h18a3 3 0 0 0 3-3V7a3 3 0 0 0-3-3ZM5.5 9.786c0-.71.672-1.286 1.5-1.286h18c.828 0 1.5.576 1.5 1.286v15.428c0 .71-.672 1.286-1.5 1.286H7c-.828 0-1.5-.576-1.5-1.286z'/%3E%3Cpath d='M13.75 14.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3zm4.5 0a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3zm4.5 0a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3zM9.25 19a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3zm4.5 0a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3zm4.5 0a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3zm4.5 0a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3zm-13.5 4.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3zm4.5 0a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3zm4.5 0a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3z'/%3E%3C/g%3E%3C/svg%3E" );
4
3
@-webkit-keyframes fpFadeInDown {
5
@-webkit-keyframes fpFadeInDown {
4
    from {
6
    from {
5
        opacity: 0;
7
        opacity: 0;
Lines 957-963 span.flatpickr-weekday { Link Here
957
}
959
}
958
960
959
.flatpickr-input {
961
.flatpickr-input {
960
    background-image: url( "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23369' class='bi bi-calendar3' viewBox='0 0 32 32'%3E%3Cg stroke-width='1.333'%3E%3Cpath d='M25 4H7a3 3 0 0 0-3 3v18a3 3 0 0 0 3 3h18a3 3 0 0 0 3-3V7a3 3 0 0 0-3-3ZM5.5 9.786c0-.71.672-1.286 1.5-1.286h18c.828 0 1.5.576 1.5 1.286v15.428c0 .71-.672 1.286-1.5 1.286H7c-.828 0-1.5-.576-1.5-1.286z'/%3E%3Cpath d='M13.75 14.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3zm4.5 0a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3zm4.5 0a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3zM9.25 19a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3zm4.5 0a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3zm4.5 0a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3zm4.5 0a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3zm-13.5 4.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3zm4.5 0a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3zm4.5 0a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3z'/%3E%3C/g%3E%3C/svg%3E" );
962
    background-image: $flatpickr-calendar-icon;
961
    background-position: 0;
963
    background-position: 0;
962
    background-repeat: no-repeat;
964
    background-repeat: no-repeat;
963
    border-style: inset;
965
    border-style: inset;
(-)a/koha-tmpl/intranet-tmpl/prog/css/src/staff-global.scss (+65 lines)
Lines 1261-1266 ol { Link Here
1261
    font-weight: bold;
1261
    font-weight: bold;
1262
}
1262
}
1263
1263
1264
// Split date input widget (generated by calendar.inc in DateInputStyle=split mode)
1265
.date-split-wrapper {
1266
    align-items: flex-end;
1267
    display: inline-flex;
1268
    flex-wrap: wrap;
1269
    gap: .4rem;
1270
1271
    .split-part-group {
1272
        display: flex;
1273
        flex-direction: column;
1274
1275
        label {
1276
            float: none;
1277
            font-size: .9em;
1278
            font-weight: normal;
1279
            margin-bottom: .2rem;
1280
            margin-right: 0;
1281
            text-align: left;
1282
            width: auto;
1283
        }
1284
    }
1285
1286
    .split-day,
1287
    .split-month {
1288
        text-align: center;
1289
        width: 3.8rem;
1290
    }
1291
1292
    .split-year {
1293
        text-align: center;
1294
        width: 5.8rem;
1295
    }
1296
1297
    .split-sep {
1298
        align-self: flex-end;
1299
        font-size: 1.2em;
1300
        font-weight: bold;
1301
        padding-bottom: .35rem;
1302
    }
1303
1304
    .split-calendar-btn,
1305
    .split-clear-btn {
1306
        align-self: flex-end;
1307
        font-size: 130%;
1308
        margin-left: .25rem;
1309
        vertical-align: bottom;
1310
    }
1311
1312
    .split-calendar-btn {
1313
        background-image: $flatpickr-calendar-icon;
1314
        background-position: center;
1315
        background-repeat: no-repeat;
1316
        background-size: contain;
1317
        display: inline-block;
1318
        height: 1em;
1319
        opacity: .75;
1320
        width: 1em;
1321
1322
        &:hover {
1323
            opacity: 1;
1324
            text-decoration: none;
1325
        }
1326
    }
1327
}
1328
1264
fieldset {
1329
fieldset {
1265
    + fieldset {
1330
    + fieldset {
1266
        &.action {
1331
        &.action {
(-)a/koha-tmpl/intranet-tmpl/prog/en/includes/calendar.inc (-1 / +184 lines)
Lines 31-36 Link Here
31
                delimiter = "-";
31
                delimiter = "-";
32
        }
32
        }
33
        var sentmsg = 0;
33
        var sentmsg = 0;
34
        var dateInputStyle = "[% Koha.Preference('DateInputStyle') | html %]";
34
        var bidi = [% IF(bidi) %] true[% ELSE %] false[% END %];
35
        var bidi = [% IF(bidi) %] true[% ELSE %] false[% END %];
35
        var calendarFirstDayOfWeek = '[% Koha.Preference('CalendarFirstDayOfWeek') | html %]';
36
        var calendarFirstDayOfWeek = '[% Koha.Preference('CalendarFirstDayOfWeek') | html %]';
36
        var flatpickr_timeformat_string = [% IF Koha.Preference('TimeFormat') == '12hr' %]"G:i K"[% ELSE %]"H:i"[% END %];
37
        var flatpickr_timeformat_string = [% IF Koha.Preference('TimeFormat') == '12hr' %]"G:i K"[% ELSE %]"H:i"[% END %];
Lines 212-217 Link Here
212
            let options = {};
213
            let options = {};
213
            let refresh_max_date = 0;
214
            let refresh_max_date = 0;
214
            let disable_buttons = [];
215
            let disable_buttons = [];
216
            let is_time      = $(input).data('flatpickr-enable-time') === true;
217
            let is_time_only = $(input).data('flatpickr-time-only') === true;
218
            let is_period    = $(input).data('flatpickr-period');
215
219
216
            if( $(input).data("flatpickr-futureinclusive") === true
220
            if( $(input).data("flatpickr-futureinclusive") === true
217
             || $(input).data("flatpickr-futuredate") === true ) {
221
             || $(input).data("flatpickr-futuredate") === true ) {
Lines 272-278 Link Here
272
                options['plugins'] = [];
276
                options['plugins'] = [];
273
            }
277
            }
274
278
275
            let fp = $(input).flatpickr(options);
279
            let fp;
280
            if ( dateInputStyle === 'split' && !is_time && !is_time_only && !is_period ) {
281
                fp = apply_split_date_widget(input, options);
282
            } else {
283
                fp = $(input).flatpickr(options);
284
            }
276
285
277
            $(disable_buttons).each(function(index, value){
286
            $(disable_buttons).each(function(index, value){
278
                $(fp.calendarContainer).find(".shortcut-buttons-flatpickr-button[data-index='"+value+"']").prop("disabled", "disabled");
287
                $(fp.calendarContainer).find(".shortcut-buttons-flatpickr-button[data-index='"+value+"']").prop("disabled", "disabled");
Lines 292-297 Link Here
292
            }
301
            }
293
        }
302
        }
294
303
304
        function apply_split_date_widget(input, fpOptions) {
305
            let parts_order, sep;
306
            switch (dateformat_pref) {
307
                case 'us':    parts_order = ['month', 'day', 'year']; sep = '/'; break;
308
                case 'iso':
309
                case 'sql':   parts_order = ['year', 'month', 'day']; sep = '-'; break;
310
                case 'dmydot': parts_order = ['day', 'month', 'year']; sep = '.'; break;
311
                default:      parts_order = ['day', 'month', 'year']; sep = '/'; /* metric */
312
            }
313
            let labels       = { day: __("Day"),   month: __("Month"), year: __("Year") };
314
            let placeholders = { day: 'DD',        month: 'MM',        year: 'YYYY'     };
315
            let autocompletes = { day: 'bday-day', month: 'bday-month', year: 'bday-year' };
316
            let inputId = input.id || ('fp_split_' + Math.random().toString(36).substr(2, 8));
317
318
            let options = Object.assign({}, fpOptions, {
319
                clickOpens: false,
320
                onReady: function(selectedDates, dateStr, instance) {
321
                    /* Hide the altInput; our split inputs replace it visually */
322
                    $(instance.altInput)
323
                        .addClass('visually-hidden')
324
                        .attr('tabindex', '-1')
325
                        .attr('aria-hidden', 'true');
326
327
                    let $wrapper = $('<div class="date-split-wrapper"></div>')
328
                        .attr('data-split-for', inputId);
329
330
                    for (let i = 0; i < parts_order.length; i++) {
331
                        let part   = parts_order[i];
332
                        let partId = inputId + '_' + part;
333
                        let $group = $('<span class="split-part-group"></span>');
334
                        $group.append(
335
                            $('<label></label>').attr('for', partId).text(labels[part])
336
                        );
337
                        $group.append(
338
                            $('<input type="text" inputmode="numeric" pattern="[0-9]*">')
339
                                .attr('id', partId)
340
                                .attr('maxlength', part === 'year' ? 4 : 2)
341
                                .attr('placeholder', placeholders[part])
342
                                .attr('autocomplete', autocompletes[part])
343
                                .addClass('split-part split-' + part)
344
                        );
345
                        $wrapper.append($group);
346
                        if (i < parts_order.length - 1) {
347
                            $wrapper.append(
348
                                $('<span class="split-sep" aria-hidden="true"></span>').text(sep)
349
                            );
350
                        }
351
                    }
352
353
                    let $calBtn = $('<a href="#" class="split-calendar-btn"></a>')
354
                        .attr('title', __("Open calendar"))
355
                        .attr('aria-label', __("Open calendar"))
356
                        .attr('aria-hidden', 'true');
357
                    let $clearBtn = $('<a href="#" class="clear_date split-clear-btn fa fa-fw fa-times"></a>')
358
                        .attr('aria-label', __("Clear date"))
359
                        .attr('aria-hidden', 'true');
360
                    $wrapper.append($calBtn).append($clearBtn);
361
362
                    $(instance.altInput).after($wrapper);
363
364
                    /* Populate split inputs from the current ISO value (edit mode) */
365
                    let val = $(input).val();
366
                    if (val && /^\d{4}-\d{2}-\d{2}$/.test(val)) {
367
                        let p = val.split('-');
368
                        $wrapper.find('.split-year').val(p[0]);
369
                        $wrapper.find('.split-month').val(p[1]);
370
                        $wrapper.find('.split-day').val(p[2]);
371
                    }
372
373
                    let syncing = false;
374
375
                    /* Split inputs -> flatpickr ISO */
376
                    $wrapper.on('input', '.split-part', function() {
377
                        let d = $wrapper.find('.split-day').val().trim();
378
                        let m = $wrapper.find('.split-month').val().trim();
379
                        let y = $wrapper.find('.split-year').val().trim();
380
                        syncing = true;
381
                        if (d && m && y && y.length === 4) {
382
                            instance.setDate(y + '-' + m.padStart(2, '0') + '-' + d.padStart(2, '0'), false);
383
                        } else {
384
                            instance.clear(false);
385
                            $(input).val('');
386
                        }
387
                        $(input).trigger('change');
388
                        syncing = false;
389
                    });
390
391
                    /* Auto-advance from day/month after 2 digits (skip on delete/backspace) */
392
                    $wrapper.on('input', '.split-day, .split-month', function(e) {
393
                        let inputType = e.originalEvent && e.originalEvent.inputType;
394
                        if ($(this).val().length >= 2 && inputType && !inputType.startsWith('delete')) {
395
                            let $parts = $wrapper.find('.split-part');
396
                            let idx = $parts.index(this);
397
                            if (idx < $parts.length - 1) {
398
                                $parts.eq(idx + 1).focus().select();
399
                            }
400
                        }
401
                    });
402
403
                    /* Calendar date selected -> populate split inputs */
404
                    $(input).on('change', function() {
405
                        if (syncing) return;
406
                        let v = $(input).val();
407
                        if (v && /^\d{4}-\d{2}-\d{2}$/.test(v)) {
408
                            let p = v.split('-');
409
                            $wrapper.find('.split-year').val(p[0]);
410
                            $wrapper.find('.split-month').val(p[1]);
411
                            $wrapper.find('.split-day').val(p[2]);
412
                        } else {
413
                            $wrapper.find('.split-year, .split-month, .split-day').val('');
414
                        }
415
                    });
416
417
                    /* Calendar button opens the flatpickr calendar */
418
                    $calBtn.on('click', function(e) {
419
                        e.preventDefault();
420
                        instance.open();
421
                    });
422
423
                    /* Clear button clears all split inputs and the flatpickr value */
424
                    $clearBtn.on('click', function(e) {
425
                        e.preventDefault();
426
                        syncing = true;
427
                        instance.clear();
428
                        $(input).val('');
429
                        $wrapper.find('.split-year, .split-month, .split-day').val('');
430
                        syncing = false;
431
                    });
432
                }
433
            });
434
435
            return $(input).flatpickr(options);
436
        }
437
438
        function validateSplitDateInputs() {
439
            let valid = true;
440
            $('.date-split-wrapper').each(function() {
441
                let $wrapper  = $(this);
442
                let origId    = $wrapper.data('split-for');
443
                let $orig     = origId ? $('#' + origId) : null;
444
                let isRequired = $orig && $orig.is('[required]');
445
                let d = $wrapper.find('.split-day').val().trim();
446
                let m = $wrapper.find('.split-month').val().trim();
447
                let y = $wrapper.find('.split-year').val().trim();
448
                let anyFilled = d || m || y;
449
                let allFilled = d && m && y && y.length === 4;
450
451
                if (isRequired && !allFilled) {
452
                    alert(__("Please enter a complete date."));
453
                    $wrapper.find('.split-part').first().focus();
454
                    valid = false;
455
                    return false;
456
                }
457
                if (anyFilled && !allFilled) {
458
                    alert(__("Please complete all parts of the date field."));
459
                    $wrapper.find('.split-part').first().focus();
460
                    valid = false;
461
                    return false;
462
                }
463
                if (allFilled) {
464
                    let dt = new Date(parseInt(y, 10), parseInt(m, 10) - 1, parseInt(d, 10));
465
                    if ( dt.getFullYear() !== parseInt(y, 10)
466
                      || dt.getMonth()    !== parseInt(m, 10) - 1
467
                      || dt.getDate()     !== parseInt(d, 10) ) {
468
                        alert(__("Please enter a valid date."));
469
                        $wrapper.find('.split-part').first().focus();
470
                        valid = false;
471
                        return false;
472
                    }
473
                }
474
            });
475
            return valid;
476
        }
477
295
        $(document).ready(function(){
478
        $(document).ready(function(){
296
            $(".flatpickr").each(function(){
479
            $(".flatpickr").each(function(){
297
                apply_flatpickr(this);
480
                apply_flatpickr(this);
(-)a/koha-tmpl/intranet-tmpl/prog/en/modules/admin/preferences/i18n_l10n.pref (+8 lines)
Lines 10-15 I18N/L10N: Link Here
10
              dmydot: dd.mm.yyyy
10
              dmydot: dd.mm.yyyy
11
              iso: yyyy-mm-dd
11
              iso: yyyy-mm-dd
12
        - .
12
        - .
13
    -
14
        - Use
15
        - pref: DateInputStyle
16
          default: single
17
          choices:
18
              single: single input field with calendar picker
19
              split: split inputs (day, month, year) with optional calendar button
20
        - "style for date entry fields. The split option follows the dateformat preference for field order and is recommended for accessibility; it does not apply to time, datetime, or booking date range fields."
13
    -
21
    -
14
        - Format times in
22
        - Format times in
15
        - pref: TimeFormat
23
        - pref: TimeFormat
(-)a/koha-tmpl/intranet-tmpl/prog/en/modules/members/memberentrygen.tt (-1 / +1 lines)
Lines 1804-1810 Link Here
1804
            });
1804
            });
1805
1805
1806
            $("#dateofbirth").on("change", function(){
1806
            $("#dateofbirth").on("change", function(){
1807
                 write_age();
1807
                write_age();
1808
            });
1808
            });
1809
1809
1810
            $("#debarred_comment, #debarred_expiration").on("change", function(){
1810
            $("#debarred_comment, #debarred_expiration").on("change", function(){
(-)a/koha-tmpl/intranet-tmpl/prog/js/members.js (-2 / +12 lines)
Lines 224-230 function write_age() { Link Here
224
    var hint = $("#dateofbirth_hint");
224
    var hint = $("#dateofbirth_hint");
225
    hint.html(dateformat);
225
    hint.html(dateformat);
226
226
227
    var age = CalculateAge(document.form.dateofbirth.value);
227
    var dobVal =
228
        document.form && document.form.dateofbirth
229
            ? document.form.dateofbirth.value
230
            : $("#dateofbirth").val();
231
    var age = CalculateAge(dobVal);
228
232
229
    if (!age.year && !age.month) {
233
    if (!age.year && !age.month) {
230
        return;
234
        return;
Lines 319-325 $(document).ready(function () { Link Here
319
        ).val(matches[4]);
323
        ).val(matches[4]);
320
    });
324
    });
321
325
322
    dateformat = $("#dateofbirth").siblings(".hint").first().html();
326
    dateformat = $("#dateofbirth_hint").html() || "";
323
327
324
    if ($("#dateofbirth").length) {
328
    if ($("#dateofbirth").length) {
325
        write_age();
329
        write_age();
Lines 362-367 $(document).ready(function () { Link Here
362
            },
366
            },
363
        },
367
        },
364
        submitHandler: function (form) {
368
        submitHandler: function (form) {
369
            if (
370
                typeof validateSplitDateInputs === "function" &&
371
                !validateSplitDateInputs()
372
            ) {
373
                return false;
374
            }
365
            $(
375
            $(
366
                "body, form input[type='submit'], form button[type='submit'], form a"
376
                "body, form input[type='submit'], form button[type='submit'], form a"
367
            ).addClass("waiting");
377
            ).addClass("waiting");
(-)a/koha-tmpl/opac-tmpl/bootstrap/css/src/_flatpickr.scss (-1 / +3 lines)
Lines 1-5 Link Here
1
@import "flatpickr_vars";
1
@import "flatpickr_vars";
2
2
3
$flatpickr-calendar-icon: url( "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='%23369' class='bi bi-calendar3'%3E%3Cg stroke-width='1.333'%3E%3Cpath d='M10.5 0h-9A1.5 1.5 0 0 0 0 1.5v9A1.5 1.5 0 0 0 1.5 12h9a1.5 1.5 0 0 0 1.5-1.5v-9A1.5 1.5 0 0 0 10.5 0zM.75 2.893c0-.355.336-.643.75-.643h9c.414 0 .75.288.75.643v7.714c0 .355-.336.643-.75.643h-9c-.414 0-.75-.288-.75-.643z'/%3E%3Cpath d='M4.875 5.25a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5zm2.25 0a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5zm2.25 0a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5zM2.625 7.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5zm2.25 0a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5zm2.25 0a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5zm2.25 0a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5zm-6.75 2.25a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5zm2.25 0a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5zm2.25 0a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5z'/%3E%3C/g%3E%3C/svg%3E" );
4
3
@keyframes fpFadeInDown {
5
@keyframes fpFadeInDown {
4
    from {
6
    from {
5
        opacity: 0;
7
        opacity: 0;
Lines 836-842 span.flatpickr-weekday { Link Here
836
}
838
}
837
839
838
.flatpickr-input {
840
.flatpickr-input {
839
    background-image: url( "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='%23369' class='bi bi-calendar3'%3E%3Cg stroke-width='1.333'%3E%3Cpath d='M10.5 0h-9A1.5 1.5 0 0 0 0 1.5v9A1.5 1.5 0 0 0 1.5 12h9a1.5 1.5 0 0 0 1.5-1.5v-9A1.5 1.5 0 0 0 10.5 0zM.75 2.893c0-.355.336-.643.75-.643h9c.414 0 .75.288.75.643v7.714c0 .355-.336.643-.75.643h-9c-.414 0-.75-.288-.75-.643z'/%3E%3Cpath d='M4.875 5.25a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5zm2.25 0a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5zm2.25 0a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5zM2.625 7.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5zm2.25 0a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5zm2.25 0a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5zm2.25 0a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5zm-6.75 2.25a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5zm2.25 0a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5zm2.25 0a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5z'/%3E%3C/g%3E%3C/svg%3E" );
841
    background-image: $flatpickr-calendar-icon;
840
    background-position: 3px;
842
    background-position: 3px;
841
    background-repeat: no-repeat;
843
    background-repeat: no-repeat;
842
    border-style: inset;
844
    border-style: inset;
(-)a/koha-tmpl/opac-tmpl/bootstrap/css/src/opac.scss (+65 lines)
Lines 926-931 div { Link Here
926
    }
926
    }
927
}
927
}
928
928
929
// Split date input widget (generated by calendar.inc in DateInputStyle=split mode)
930
.date-split-wrapper {
931
    align-items: flex-end;
932
    display: inline-flex;
933
    flex-wrap: wrap;
934
    gap: .4rem;
935
936
    .split-part-group {
937
        display: flex;
938
        flex-direction: column;
939
940
        label {
941
            float: none;
942
            font-size: .9em;
943
            font-weight: normal;
944
            margin-bottom: .2rem;
945
            margin-right: 0;
946
            text-align: left;
947
            width: auto;
948
        }
949
    }
950
951
    .split-day,
952
    .split-month {
953
        text-align: center;
954
        width: 3.8rem;
955
    }
956
957
    .split-year {
958
        text-align: center;
959
        width: 5.8rem;
960
    }
961
962
    .split-sep {
963
        align-self: flex-end;
964
        font-size: 1.2em;
965
        font-weight: bold;
966
        padding-bottom: .35rem;
967
    }
968
969
    .split-calendar-btn,
970
    .split-clear-btn {
971
        align-self: flex-end;
972
        font-size: 130%;
973
        margin-left: .25rem;
974
        vertical-align: bottom;
975
    }
976
977
    .split-calendar-btn {
978
        background-image: $flatpickr-calendar-icon;
979
        background-position: center;
980
        background-repeat: no-repeat;
981
        background-size: contain;
982
        display: inline-block;
983
        height: 1em;
984
        opacity: .75;
985
        width: 1em;
986
987
        &:hover {
988
            opacity: 1;
989
            text-decoration: none;
990
        }
991
    }
992
}
993
929
fieldset {
994
fieldset {
930
    &.rows {
995
    &.rows {
931
        clear: left;
996
        clear: left;
(-)a/koha-tmpl/opac-tmpl/bootstrap/en/includes/calendar.inc (-1 / +190 lines)
Lines 13-18 Link Here
13
    };
13
    };
14
    var debug    = "[% debug | html %]";
14
    var debug    = "[% debug | html %]";
15
    var dateformat_pref = "[% Koha.Preference('dateformat') | html %]";
15
    var dateformat_pref = "[% Koha.Preference('dateformat') | html %]";
16
    var dateInputStyle = "[% Koha.Preference('DateInputStyle') | html %]";
16
    var sentmsg = 0;
17
    var sentmsg = 0;
17
    if (debug > 1) {alert("dateformat: " + dateformat_pref + "\ndebug is on (level " + debug + ")");}
18
    if (debug > 1) {alert("dateformat: " + dateformat_pref + "\ndebug is on (level " + debug + ")");}
18
    var calendarFirstDayOfWeek = '[% Koha.Preference('CalendarFirstDayOfWeek') | html %]';
19
    var calendarFirstDayOfWeek = '[% Koha.Preference('CalendarFirstDayOfWeek') | html %]';
Lines 161-171 Link Here
161
        }
162
        }
162
    }
163
    }
163
164
165
    function apply_split_date_widget(input, fpOptions) {
166
        let parts_order, sep;
167
        switch (dateformat_pref) {
168
            case "us":
169
                parts_order = ["month", "day", "year"];
170
                sep = "/";
171
                break;
172
            case "iso":
173
            case "sql":
174
                parts_order = ["year", "month", "day"];
175
                sep = "-";
176
                break;
177
            case "dmydot":
178
                parts_order = ["day", "month", "year"];
179
                sep = ".";
180
                break;
181
            default:
182
                parts_order = ["day", "month", "year"];
183
                sep = "/"; // metric
184
        }
185
        let labels = { day: _("Day"), month: _("Month"), year: _("Year") };
186
        let placeholders = { day: "DD", month: "MM", year: "YYYY" };
187
        let autocompletes = { day: "bday-day", month: "bday-month", year: "bday-year" };
188
        let inputId = input.id || "fp_split_" + Math.random().toString(36).substr(2, 8);
189
190
        let options = Object.assign({}, fpOptions, {
191
            clickOpens: false,
192
            onReady: function (selectedDates, dateStr, instance) {
193
                // Handle pastdate / futuredate CSS classes (normally in default onReady)
194
                if ($(instance.input).hasClass("pastdate")) {
195
                    instance.set("maxDate", new Date().fp_incr(-1));
196
                }
197
                if ($(instance.input).hasClass("futuredate")) {
198
                    instance.set("minDate", new Date().fp_incr(1));
199
                }
200
201
                // Hide the altInput; our split inputs replace it visually
202
                $(instance.altInput).addClass("visually-hidden").attr("tabindex", "-1").attr("aria-hidden", "true");
203
204
                let $wrapper = $('<div class="date-split-wrapper"></div>').attr("data-split-for", inputId);
205
206
                for (let i = 0; i < parts_order.length; i++) {
207
                    let part = parts_order[i];
208
                    let partId = inputId + "_" + part;
209
                    let $group = $('<span class="split-part-group"></span>');
210
                    $group.append($("<label></label>").attr("for", partId).text(labels[part]));
211
                    $group.append(
212
                        $('<input type="text" inputmode="numeric" pattern="[0-9]*">')
213
                            .attr("id", partId)
214
                            .attr("maxlength", part === "year" ? 4 : 2)
215
                            .attr("placeholder", placeholders[part])
216
                            .attr("autocomplete", autocompletes[part])
217
                            .addClass("split-part split-" + part)
218
                    );
219
                    $wrapper.append($group);
220
                    if (i < parts_order.length - 1) {
221
                        $wrapper.append($('<span class="split-sep" aria-hidden="true"></span>').text(sep));
222
                    }
223
                }
224
225
                let $calBtn = $('<a href="#" class="split-calendar-btn"></a>').attr("title", _("Open calendar")).attr("aria-label", _("Open calendar")).attr("aria-hidden", "true");
226
                let $clearBtn = $('<a href="#" class="clear_date split-clear-btn fa fa-fw fa-times"></a>').attr("aria-label", _("Clear date")).attr("aria-hidden", "true");
227
                $wrapper.append($calBtn).append($clearBtn);
228
229
                $(instance.altInput).after($wrapper);
230
231
                // Populate split inputs from the current ISO value (edit mode)
232
                let val = $(input).val();
233
                if (val && /^\d{4}-\d{2}-\d{2}$/.test(val)) {
234
                    let p = val.split("-");
235
                    $wrapper.find(".split-year").val(p[0]);
236
                    $wrapper.find(".split-month").val(p[1]);
237
                    $wrapper.find(".split-day").val(p[2]);
238
                }
239
240
                let syncing = false;
241
242
                // Split inputs → flatpickr ISO
243
                $wrapper.on("input", ".split-part", function () {
244
                    let d = $wrapper.find(".split-day").val().trim();
245
                    let m = $wrapper.find(".split-month").val().trim();
246
                    let y = $wrapper.find(".split-year").val().trim();
247
                    syncing = true;
248
                    if (d && m && y && y.length === 4) {
249
                        instance.setDate(y + "-" + m.padStart(2, "0") + "-" + d.padStart(2, "0"), false);
250
                    } else {
251
                        instance.clear(false);
252
                        $(input).val("");
253
                    }
254
                    $(input).trigger("change");
255
                    syncing = false;
256
                });
257
258
                // Auto-advance from day/month after 2 digits (skip on delete/backspace)
259
                $wrapper.on("input", ".split-day, .split-month", function (e) {
260
                    let inputType = e.originalEvent && e.originalEvent.inputType;
261
                    if ($(this).val().length >= 2 && inputType && !inputType.startsWith("delete")) {
262
                        let $parts = $wrapper.find(".split-part");
263
                        let idx = $parts.index(this);
264
                        if (idx < $parts.length - 1) {
265
                            $parts
266
                                .eq(idx + 1)
267
                                .focus()
268
                                .select();
269
                        }
270
                    }
271
                });
272
273
                // Calendar date selected → populate split inputs
274
                $(input).on("change", function () {
275
                    if (syncing) return;
276
                    let v = $(input).val();
277
                    if (v && /^\d{4}-\d{2}-\d{2}$/.test(v)) {
278
                        let p = v.split("-");
279
                        $wrapper.find(".split-year").val(p[0]);
280
                        $wrapper.find(".split-month").val(p[1]);
281
                        $wrapper.find(".split-day").val(p[2]);
282
                    } else {
283
                        $wrapper.find(".split-year, .split-month, .split-day").val("");
284
                    }
285
                });
286
287
                // Calendar button opens the flatpickr calendar
288
                $calBtn.on("click", function (e) {
289
                    e.preventDefault();
290
                    instance.open();
291
                });
292
293
                // Clear button clears all split inputs and the flatpickr value
294
                $clearBtn.on("click", function (e) {
295
                    e.preventDefault();
296
                    syncing = true;
297
                    instance.clear();
298
                    $(input).val("");
299
                    $wrapper.find(".split-year, .split-month, .split-day").val("");
300
                    syncing = false;
301
                });
302
            },
303
        });
304
305
        return $(input).flatpickr(options);
306
    }
307
308
    function validateSplitDateInputs() {
309
        let valid = true;
310
        $(".date-split-wrapper").each(function () {
311
            let $wrapper = $(this);
312
            let origId = $wrapper.data("split-for");
313
            let $orig = origId ? $("#" + origId) : null;
314
            let isRequired = $orig && $orig.is("[required]");
315
            let d = $wrapper.find(".split-day").val().trim();
316
            let m = $wrapper.find(".split-month").val().trim();
317
            let y = $wrapper.find(".split-year").val().trim();
318
            let anyFilled = d || m || y;
319
            let allFilled = d && m && y && y.length === 4;
320
321
            if (isRequired && !allFilled) {
322
                alert(_("Please enter a complete date."));
323
                $wrapper.find(".split-part").first().focus();
324
                valid = false;
325
                return false;
326
            }
327
            if (anyFilled && !allFilled) {
328
                alert(_("Please complete all parts of the date field."));
329
                $wrapper.find(".split-part").first().focus();
330
                valid = false;
331
                return false;
332
            }
333
            if (allFilled) {
334
                let dt = new Date(parseInt(y, 10), parseInt(m, 10) - 1, parseInt(d, 10));
335
                if (dt.getFullYear() !== parseInt(y, 10) || dt.getMonth() !== parseInt(m, 10) - 1 || dt.getDate() !== parseInt(d, 10)) {
336
                    alert(_("Please enter a valid date."));
337
                    $wrapper.find(".split-part").first().focus();
338
                    valid = false;
339
                    return false;
340
                }
341
            }
342
        });
343
        return valid;
344
    }
345
164
    $(document).ready(function () {
346
    $(document).ready(function () {
165
        $(".flatpickr").each(function () {
347
        $(".flatpickr").each(function () {
166
            let options = {};
348
            let options = {};
167
            let refresh_max_date = 0;
349
            let refresh_max_date = 0;
168
            let disable_buttons = [];
350
            let disable_buttons = [];
351
            let is_time = $(this).data("flatpickr-enable-time") === true;
352
            let is_period = $(this).data("flatpickr-period");
169
353
170
            if ($(this).data("flatpickr-futureinclusive") === true || $(this).data("flatpickr-futuredate") === true) {
354
            if ($(this).data("flatpickr-futureinclusive") === true || $(this).data("flatpickr-futuredate") === true) {
171
                let original_date = $(this).val();
355
                let original_date = $(this).val();
Lines 209-215 Link Here
209
                options["dateFormat"] = flatpickr_dateformat_string + " " + flatpickr_timeformat_string;
393
                options["dateFormat"] = flatpickr_dateformat_string + " " + flatpickr_timeformat_string;
210
            }
394
            }
211
395
212
            let fp = $(this).flatpickr(options);
396
            let fp;
397
            if (typeof dateInputStyle !== "undefined" && dateInputStyle === "split" && !is_time && !is_period) {
398
                fp = apply_split_date_widget(this, options);
399
            } else {
400
                fp = $(this).flatpickr(options);
401
            }
213
402
214
            $(disable_buttons).each(function (index, value) {
403
            $(disable_buttons).each(function (index, value) {
215
                $(fp.calendarContainer)
404
                $(fp.calendarContainer)
(-)a/koha-tmpl/opac-tmpl/bootstrap/en/modules/opac-memberentry.tt (-3 / +3 lines)
Lines 483-489 Link Here
483
                                            [% UNLESS hidden.defined('dateofbirth') %]
483
                                            [% UNLESS hidden.defined('dateofbirth') %]
484
                                                <li>
484
                                                <li>
485
                                                    <label for="borrower_dateofbirth" class="[% required.dateofbirth | html %]">Date of birth:</label>
485
                                                    <label for="borrower_dateofbirth" class="[% required.dateofbirth | html %]">Date of birth:</label>
486
487
                                                    <input
486
                                                    <input
488
                                                        type="text"
487
                                                        type="text"
489
                                                        id="borrower_dateofbirth"
488
                                                        id="borrower_dateofbirth"
Lines 493-499 Link Here
493
                                                        class="[% required.dateofbirth | html %] flatpickr pastdate"
492
                                                        class="[% required.dateofbirth | html %] flatpickr pastdate"
494
                                                        [% IF required.dateofbirth %]required[% END %]
493
                                                        [% IF required.dateofbirth %]required[% END %]
495
                                                    />
494
                                                    />
496
497
                                                    <div class="required_label [% required.dateofbirth | html %]">Required</div>
495
                                                    <div class="required_label [% required.dateofbirth | html %]">Required</div>
498
                                                </li>
496
                                                </li>
499
                                            [% END %]
497
                                            [% END %]
Lines 1426-1431 Link Here
1426
                        }
1424
                        }
1427
                    },
1425
                    },
1428
                    submitHandler: function(form) {
1426
                    submitHandler: function(form) {
1427
                        if (typeof validateSplitDateInputs === "function" && !validateSplitDateInputs()) {
1428
                            return false;
1429
                        }
1429
                        if (form.beenSubmitted) {
1430
                        if (form.beenSubmitted) {
1430
                            return false;
1431
                            return false;
1431
                        }
1432
                        }
1432
- 

Return to bug 33886