PageSourceSearch

https://moshicom.com/assets/js/range-calendar.js

js moshicom.com collected 2026-10-01 12:27:31 UTC 10,794 bytes, 384 lines download raw bytes

1// =========================
2// Range Datepicker in Dropdown (Bootstrap4 + jQuery UI)
3// UX改善版:
4// - 1日だけ選ぶ場合:日付を1回選んで「選択する」押せばOK(開始のみで確定)
5// - 開始のみのときもカレンダー上で開始日をハイライト
6// - 終了入力を押しても開始が未設定なら開始に戻す(迷子防止)
7// - 「選択する」ボタンは開始日が無いと押せない
8// - 既存入力値があれば開いた時に取り込む(readonlyでも保険)
9// =========================
10(function () {
11  // =========================
12  // elements
13  // =========================
14  const $menu = $('#menuDate');
15  const $toggle = $('#toggleDate');
16  const $from = $('#dateFrom');
17  const $to = $('#dateTo');
18  const $cal = $('#rangeCalendar');
19  const $btnClear = $('#headerDateClear');
20  const $btnApply = $menu.find('.dropdown-close');
21
22  const mq = window.matchMedia('(min-width: 768px)');
23
24  // =========================
25  // state
26  // =========================
27  let startDate = null; // Date (stripTime済み推奨)
28  let endDate = null;   // Date (stripTime済み推奨)
29  let activeField = 'from'; // 'from' | 'to'
30
31  // =========================
32  // helpers
33  // =========================
34  function pad2(n) {
35    return String(n).padStart(2, '0');
36  }
37
38  function stripTime(d) {
39    return new Date(d.getFullYear(), d.getMonth(), d.getDate());
40  }
41
42  function parseYMD(str) {
43    const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(str);
44    if (!m) return null;
45    const y = Number(m[1]), mo = Number(m[2]) - 1, da = Number(m[3]);
46    const dt = new Date(y, mo, da);
47    if (dt.getFullYear() !== y || dt.getMonth() !== mo || dt.getDate() !== da) return null;
48    return stripTime(dt);
49  }
50
51  function toYMD(date) {
52    return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
53  }
54
55  function firstDayOfThisMonth() {
56    const now = new Date();
57    return new Date(now.getFullYear(), now.getMonth(), 1);
58  }
59
60  function getToday() {
61    return stripTime(new Date());
62  }
63
64  function sameDay(a, b) {
65    if (!a || !b) return false;
66    return a.getTime() === b.getTime();
67  }
68
69  function reflectInputs() {
70    $from.val(startDate ? toYMD(startDate) : '');
71    $to.val(endDate ? toYMD(endDate) : '');
72  }
73
74  function updateToggleLabel() {
75    // 1日だけ(開始のみ)でも検索できる想定:ラベルは「YYYY-MM-DD」または「YYYY-MM-DD〜」
76    if (startDate && endDate) {
77      $toggle.text(`${toYMD(startDate)}〜${toYMD(endDate)}`);
78    } else if (startDate && !endDate) {
79      // 好みで「〜」を付けたい場合は下の行に変更
80      $toggle.text(`${toYMD(startDate)}〜`);
81      // $toggle.text(`${toYMD(startDate)}`);
82    } else {
83      $toggle.text('選択');
84    }
85  }
86
87  function updateActiveInput() {
88    $from.removeClass('is-active');
89    $to.removeClass('is-active');
90    if (activeField === 'from') $from.addClass('is-active');
91    if (activeField === 'to') $to.addClass('is-active');
92  }
93
94  // TODO: ボタンは常に有効なため現在この関数は何もしていない。削除候補。
95  function updateApplyState() {
96    $btnApply.prop('disabled', false);
97    $btnApply.removeClass('disabled');
98  }
99
100  // 未選択なのに「今日」が current-day/active になる癖を強制除去
101  function stripImplicitActive() {
102    if (startDate || endDate) return;
103
104    const $cur = $cal.find('td.ui-datepicker-current-day');
105    $cur.removeClass('ui-datepicker-current-day');
106    $cur.find('a.ui-state-active').removeClass('ui-state-active');
107  }
108
109  function refreshAndStrip() {
110    if (!$cal.hasClass('hasDatepicker')) return;
111    $cal.datepicker('refresh');
112
113    requestAnimationFrame(() => {
114      stripImplicitActive();
115    });
116  }
117
118  function syncStateFromInputs() {
119    // readonlyでも、外部からvalが入る可能性の保険(初期値や復元)
120    const from = parseYMD($from.val());
121    const to = parseYMD($to.val());
122
123    startDate = from;
124    endDate = to;
125
126    // 不正な組み合わせ補正
127    if (startDate && endDate && endDate < startDate) {
128      const tmp = startDate;
129      startDate = endDate;
130      endDate = tmp;
131    }
132    if (!startDate) endDate = null;
133  }
134
135  function clearAll() {
136    startDate = null;
137    endDate = null;
138    activeField = 'from';
139
140    reflectInputs();
141    updateToggleLabel();
142    updateActiveInput();
143    updateApplyState();
144
145    rebuildDatepicker();
146  }
147
148  // =========================
149  // Holidays (year cache)
150  // =========================
151  const holidayMap = {};
152  const holidayYearLoaded = {};
153
154  function fetchHolidayYear(year) {
155    if (holidayYearLoaded[year]) return Promise.resolve();
156    holidayYearLoaded[year] = true;
157
158    const url = `https://holidays-jp.github.io/api/v1/${year}/date.json`;
159    return fetch(url, { cache: 'force-cache' })
160      .then(r => {
161        if (!r.ok) throw new Error(`holiday fetch failed: ${r.status}`);
162        return r.json();
163      })
164      .then(obj => {
165        Object.assign(holidayMap, obj);
166      })
167      .catch(err => {
168        console.warn(err);
169      });
170  }
171
172  // =========================
173  // Datepicker build
174  // =========================
175  function rebuildDatepicker() {
176    if ($cal.hasClass('hasDatepicker')) {
177      $cal.datepicker('destroy');
178    }
179
180    const months = mq.matches ? 2 : 1;
181
182    const baseYear = (startDate || new Date()).getFullYear();
183    Promise.all([
184      fetchHolidayYear(baseYear - 1),
185      fetchHolidayYear(baseYear),
186      fetchHolidayYear(baseYear + 1),
187    ]).then(() => {
188      requestAnimationFrame(refreshAndStrip);
189    });
190
191    $cal.datepicker({
192      numberOfMonths: months,
193      showOtherMonths: false,
194      selectOtherMonths: false,
195
196      firstDay: 0,
197      dateFormat: 'yy-mm-dd',
198
199      prevText: '<前',
200      nextText: '次>',
201
202      beforeShow: function () {
203        requestAnimationFrame(() => {
204          stripImplicitActive();
205        });
206      },
207
208      onChangeMonthYear: function (year) {
209        fetchHolidayYear(year).then(() => {
210          refreshAndStrip();
211        });
212      },
213
214      beforeShowDay: function (date) {
215        const day = date.getDay();
216        let cls = '';
217        if (day === 0) cls += ' is-sun';
218        if (day === 6) cls += ' is-sat';
219
220        const key = toYMD(date);
221        const holidayName = holidayMap[key];
222        if (holidayName) cls += ' is-holiday';
223
224        const t = stripTime(date).getTime();
225
226        // 開始のみでも開始日をハイライト(1日選択の使い勝手UP)
227        if (startDate && !endDate) {
228          const s = startDate.getTime();
229          if (t === s) return [true, `ui-state-range-start${cls}`, holidayName || ''];
230        }
231
232        // 範囲ハイライト
233        if (startDate && endDate) {
234          const s = startDate.getTime();
235          const e = endDate.getTime();
236
237          if (t === s) return [true, `ui-state-range-start${cls}`, holidayName || ''];
238          if (t === e) return [true, `ui-state-range-end${cls}`, holidayName || ''];
239          if (t > s && t < e) return [true, `ui-state-range-middle${cls}`, holidayName || ''];
240        }
241
242        return [true, cls.trim(), holidayName || ''];
243      },
244
245      onSelect: function (dateText) {
246        const picked = parseYMD(dateText);
247        if (!picked) return;
248
249        if (activeField === 'from') {
250          // 開始を選び直したら終了はクリア(分かりやすさ優先)
251          startDate = picked;
252          endDate = null;
253          activeField = 'to';
254        } else {
255          // 終了入力中
256          if (!startDate) {
257            startDate = picked;
258            endDate = null;
259            activeField = 'to';
260          } else {
261            // 同日なら「1日だけの範囲」として確定(開始=終了)
262            if (sameDay(picked, startDate)) {
263              endDate = picked;
264            } else if (picked < startDate) {
265              endDate = startDate;
266              startDate = picked;
267            } else {
268              endDate = picked;
269            }
270            activeField = 'from';
271          }
272        }
273
274        reflectInputs();
275        updateToggleLabel();
276        updateActiveInput();
277        updateApplyState();
278
279        $cal.datepicker('refresh');
280      }
281    });
282
283    // 初期表示:値があれば開始日に寄せる/なければ当月1日
284    if (startDate) {
285      $cal.datepicker('setDate', startDate);
286      $cal.datepicker('refresh');
287    } else {
288      $cal.datepicker('setDate', firstDayOfThisMonth());
289      $cal.datepicker('refresh');
290    }
291
292    updateActiveInput();
293    updateApplyState();
294
295    requestAnimationFrame(() => {
296      stripImplicitActive();
297    });
298  }
299
300  // =========================
301  // dropdown behavior
302  // =========================
303  $menu.on('click', function (e) {
304    e.stopPropagation();
305  });
306
307  $toggle.on('shown.bs.dropdown', function () {
308    // 開いたタイミングで入力値を取り込む(外部からの復元/初期値対応)
309    syncStateFromInputs();
310
311    if ($cal.hasClass('hasDatepicker')) {
312      refreshAndStrip();
313    } else {
314      rebuildDatepicker();
315    }
316
317    reflectInputs();
318    updateToggleLabel();
319    updateActiveInput();
320    updateApplyState();
321  });
322
323  $from.on('click', function () {
324    activeField = 'from';
325
326    // 開始を触ったら「終了は一旦消して開始から取り直し」に寄せる
327    endDate = null;
328
329    updateActiveInput();
330    reflectInputs();
331    updateToggleLabel();
332    updateApplyState();
333
334    refreshAndStrip();
335  });
336
337  $to.on('click', function () {
338    // 開始が無いのに終了を押されたら開始に戻す(迷子防止)
339    if (!startDate) {
340      activeField = 'from';
341      updateActiveInput();
342      refreshAndStrip();
343      return;
344    }
345    activeField = 'to';
346    updateActiveInput();
347  });
348
349  // =========================
350  // buttons
351  // =========================
352  $btnClear.on('click', function (e) {
353    e.preventDefault();
354    clearAll();
355  });
356
357  $btnApply.on('click', function () {
358    // 日付が選択されている場合のみラベル/入力を同期する
359    if (startDate) {
360      reflectInputs();
361      updateToggleLabel();
362      updateApplyState();
363    }
364    // 日付の選択状態に関わらず常にドロップダウンを閉じる
365    $menu.removeClass('show');
366    $toggle.attr('aria-expanded', 'false');
367  });
368
369  // =========================
370  // init
371  // =========================
372  document.addEventListener('DOMContentLoaded', function () {
373    syncStateFromInputs();
374    rebuildDatepicker();
375    reflectInputs();
376    updateToggleLabel();
377    updateActiveInput();
378    updateApplyState();
379  });
380
381  mq.addEventListener('change', function () {
382    rebuildDatepicker();
383  });
384})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.