[READ-ONLY] Mirror of https://github.com/probablykasper/date-picker-svelte. Date and time picker for Svelte date-picker-svelte.kasper.space
calendar date date-picker date-time-picker datepicker package svelte time
0

Configure Feed

Select the types of activity you want to include in your feed.

feat/timepicker (#69)

Co-authored-by: Kasper <kasperkh.kh@gmail.com>

authored by

nihanmubashshir
Kasper
and committed by
GitHub
(Nov 3, 2023, 7:14 AM +0100) 8fda5d85 c660ba33

+270 -26
+3
CHANGELOG.md
··· 1 1 # Changelog 2 2 3 + ## Next 4 + - Add `timePrecision` prop for showing a time picker (@nihanmubashshir) 5 + 3 6 ## 2.8.0 - 2023 Sep 29 4 7 - Add date to `select` event detail 5 8
+4
src/lib/DateInput.svelte
··· 94 94 /** Wait with updating the date until a date is selected */ 95 95 export let browseWithoutSelecting = false 96 96 97 + /** Show a time picker with the specified precision */ 98 + export let timePrecision: 'minute' | 'second' | 'millisecond' | null = null 99 + 97 100 // handle on:focusout for parent element. If the parent element loses 98 101 // focus (e.g input element), visible is set to false 99 102 function onFocusOut(e: FocusEvent) { ··· 222 225 {max} 223 226 {locale} 224 227 {browseWithoutSelecting} 228 + {timePrecision} 225 229 /> 226 230 </div> 227 231 {/if}
+13 -1
src/lib/DatePicker.svelte
··· 1 1 <script lang="ts"> 2 + import TimePicker from './TimePicker.svelte' 2 3 import { getMonthLength, getCalendarDays, type CalendarDay } from './date-utils.js' 3 4 import { getInnerLocale, type Locale } from './locale.js' 4 5 import { createEventDispatcher } from 'svelte' ··· 21 22 value = cloneDate(browseDate) 22 23 } 23 24 } 25 + 26 + /** Set the browseDate */ 24 27 function browse(d: Date) { 25 28 browseDate = clamp(d, min, max) 26 29 if (!browseWithoutSelecting && value) { ··· 32 35 33 36 /** Default Date to use */ 34 37 const defaultDate = new Date() 38 + 39 + /** Show a time picker with the specified precision */ 40 + export let timePrecision: 'minute' | 'second' | 'millisecond' | null = null 35 41 36 42 /** The earliest year the user can select */ 37 43 export let min = new Date(defaultDate.getFullYear() - 20, 0, 1) ··· 177 183 } 178 184 function keydown(e: KeyboardEvent) { 179 185 let shift = e.shiftKey || e.altKey 180 - if ((e.target as HTMLElement)?.tagName === 'SELECT') { 186 + if ( 187 + (e.target as HTMLElement)?.tagName === 'SELECT' || 188 + (e.target as HTMLElement)?.tagName === 'SPAN' 189 + ) { 190 + // Ignore date/month <select> & TimePicker <input> 181 191 return 182 192 } 183 193 if (shift) { ··· 317 327 {/each} 318 328 </div> 319 329 {/each} 330 + 331 + <TimePicker {timePrecision} {browseDate} {browse} /> 320 332 </div> 321 333 </div> 322 334
+202
src/lib/TimePicker.svelte
··· 1 + <script lang="ts"> 2 + export let browseDate: Date 3 + export let timePrecision: 'minute' | 'second' | 'millisecond' | null 4 + export let browse: (d: Date) => void 5 + 6 + let fields: (HTMLSpanElement | undefined | null)[] = [] 7 + 8 + function select(node: Node) { 9 + const selection = window.getSelection() 10 + const range = document.createRange() 11 + range.selectNodeContents(node) 12 + selection?.removeAllRanges() 13 + selection?.addRange(range) 14 + } 15 + 16 + type SpanEvent = { currentTarget: EventTarget & HTMLSpanElement } 17 + function keydown(e: KeyboardEvent & SpanEvent) { 18 + if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { 19 + const value = get_value(e.currentTarget) 20 + const delta = e.key === 'ArrowUp' ? 1 : -1 21 + set_value(e.currentTarget, value + delta, true) 22 + e.preventDefault() 23 + } else if (e.key === 'ArrowLeft' || e.key === 'ArrowRight' || ':;-,.'.includes(e.key)) { 24 + const field_index = fields.indexOf(e.currentTarget) 25 + const delta = e.key === 'ArrowLeft' ? -1 : 1 26 + const el = fields[field_index + delta] 27 + if (field_index >= 0 && el) { 28 + el.focus() 29 + return 30 + } 31 + e.preventDefault() 32 + } 33 + select(e.currentTarget) 34 + } 35 + 36 + function get_value(node: HTMLElement) { 37 + const label = get_field(node).label 38 + if (label === 'Hours') { 39 + return browseDate.getHours() 40 + } else if (label === 'Minutes') { 41 + return browseDate.getMinutes() 42 + } else if (label === 'Seconds') { 43 + return browseDate.getSeconds() 44 + } else { 45 + return browseDate.getMilliseconds() 46 + } 47 + } 48 + function clamp(value: number, max: number, loop_around: boolean) { 49 + if (loop_around && value < 0) { 50 + return max 51 + } else if (loop_around && value > max) { 52 + return 0 53 + } else { 54 + return Math.max(0, Math.min(max, value)) 55 + } 56 + } 57 + 58 + function get_field(element: HTMLElement) { 59 + const label = element.getAttribute('aria-label') 60 + if (label === 'Hours') { 61 + return { label, len: 2, max: 23 } as const 62 + } else if (label === 'Minutes') { 63 + return { label, len: 2, max: 59 } as const 64 + } else if (label === 'Seconds') { 65 + return { label, len: 2, max: 59 } as const 66 + } else { 67 + return { label, len: 3, max: 999 } as const 68 + } 69 + } 70 + 71 + function set_value(node: HTMLElement, value: number, loop_around = false) { 72 + const field = get_field(node) 73 + value = clamp(value, field.max, loop_around) 74 + if (field.label === 'Hours') { 75 + browseDate.setHours(value) 76 + } else if (field.label === 'Minutes') { 77 + browseDate.setMinutes(value) 78 + } else if (field.label === 'Seconds') { 79 + browseDate.setSeconds(value) 80 + } else if (field.label === 'Milliseconds') { 81 + browseDate.setMilliseconds(value) 82 + } else { 83 + throw new Error('Invalid label ' + field.label) 84 + } 85 + 86 + const length = field.label === 'Milliseconds' ? 3 : 2 87 + const text_value = ('000' + value).slice(-length) 88 + if (text_value !== node.innerText) { 89 + node.innerText = text_value 90 + } 91 + browse(browseDate) 92 + } 93 + 94 + function parse(text: string, length: number) { 95 + return parseInt(text.replace(/[^\d]/g, '').slice(-length)) 96 + } 97 + 98 + function input(e_unknown: unknown) { 99 + const e = e_unknown as InputEvent & SpanEvent // type error workaround 100 + 101 + const field = get_field(e.currentTarget) 102 + let new_value: number 103 + 104 + if (e.inputType === 'insertText') { 105 + const original_text = '000' + get_value(e.currentTarget) 106 + new_value = parse(original_text + e.currentTarget.innerText, field.len) 107 + if (new_value > field.max && e.data) { 108 + new_value = parse(e.data, field.len) 109 + } 110 + } else { 111 + new_value = parse('000' + e.currentTarget.innerText, field.len) 112 + } 113 + 114 + set_value(e.currentTarget, new_value) 115 + select(e.currentTarget) 116 + } 117 + 118 + function focus(e: FocusEvent & SpanEvent) { 119 + select(e.currentTarget) 120 + } 121 + </script> 122 + 123 + {#if timePrecision} 124 + <div 125 + class="time-picker" 126 + role="none" 127 + on:mousedown={(e) => { 128 + if (e.target instanceof HTMLElement && e.target.tagName === 'SPAN') { 129 + e.target.focus() 130 + e.preventDefault() // prevent text dragging 131 + } 132 + }} 133 + > 134 + <span 135 + bind:this={fields[0]} 136 + role="spinbutton" 137 + aria-label="Hours" 138 + tabindex="0" 139 + contenteditable 140 + on:keydown={keydown} 141 + on:input={input} 142 + on:focus={focus}>{('00' + browseDate.getHours()).slice(-2)}</span 143 + >: 144 + <span 145 + bind:this={fields[1]} 146 + role="spinbutton" 147 + aria-label="Minutes" 148 + tabindex="0" 149 + contenteditable 150 + on:keydown={keydown} 151 + on:input={input} 152 + on:focus={focus}>{('00' + browseDate.getMinutes()).slice(-2)}</span 153 + > 154 + {#if timePrecision !== 'minute'} 155 + :<span 156 + bind:this={fields[2]} 157 + role="spinbutton" 158 + aria-label="Seconds" 159 + tabindex="0" 160 + contenteditable 161 + on:keydown={keydown} 162 + on:input={input} 163 + on:focus={focus}>{('00' + browseDate.getSeconds()).slice(-2)}</span 164 + > 165 + {#if timePrecision !== 'second'} 166 + .<span 167 + bind:this={fields[3]} 168 + role="spinbutton" 169 + aria-label="Milliseconds" 170 + tabindex="0" 171 + contenteditable 172 + on:keydown={keydown} 173 + on:input={input} 174 + on:focus={focus}>{('000' + browseDate.getMilliseconds()).slice(-3)}</span 175 + > 176 + {/if} 177 + {/if} 178 + </div> 179 + {/if} 180 + 181 + <style lang="sass"> 182 + .time-picker 183 + font-size: 1.1em 184 + display: flex 185 + align-items: center 186 + width: fit-content 187 + border: 1px solid rgba(108, 120, 147, 0.3) 188 + border-radius: 3px 189 + margin: auto 190 + font-variant-numeric: tabular-nums 191 + margin-top: 6px 192 + span 193 + user-select: all 194 + outline: none 195 + position: relative 196 + z-index: 1 197 + padding: 4px 0px 198 + &:first-child 199 + padding-left: 6px 200 + &:last-child 201 + padding-right: 6px 202 + </style>
+7
src/routes/DateInput.svelte
··· 14 14 let browseWithoutSelecting: boolean 15 15 let format: string 16 16 let dynamicPositioning: boolean = true 17 + let timePrecision: 'minute' | 'second' | 'millisecond' | null = null 17 18 </script> 18 19 19 20 <Split> ··· 30 31 bind:closeOnSelection 31 32 bind:browseWithoutSelecting 32 33 bind:dynamicPositioning 34 + bind:timePrecision 33 35 /> 34 36 35 37 <svelte:fragment slot="right"> ··· 46 48 <Prop label="browseWithoutSelecting" bind:value={browseWithoutSelecting} /> 47 49 <Prop label="dynamicPositioning" bind:value={dynamicPositioning} /> 48 50 <Prop label="locale">Default</Prop> 51 + <Prop 52 + label="timePrecision" 53 + bind:value={timePrecision} 54 + values={[null, 'minute', 'second', 'millisecond']}>{timePrecision}</Prop 55 + > 49 56 </svelte:fragment> 50 57 </Split>
+7 -2
src/routes/DatePicker.svelte
··· 7 7 // had to import it this way to avoid errors 8 8 // in `npm run build:site` or `npm run check`: 9 9 import hy from 'date-fns/locale/hy/index.js' 10 - 11 10 let value: Date 12 11 let min: Date 13 12 let max: Date 14 13 let locale = localeFromDateFnsLocale(hy) 15 14 let browseWithoutSelecting: boolean 15 + let timePrecision: 'minute' | 'second' | 'millisecond' | null = 'millisecond' 16 16 </script> 17 17 18 18 <Split> 19 19 <div class="left" slot="left"> 20 - <DatePicker bind:value bind:min bind:max {locale} bind:browseWithoutSelecting /> 20 + <DatePicker bind:value bind:min bind:max {locale} bind:browseWithoutSelecting {timePrecision} /> 21 21 </div> 22 22 <div slot="right"> 23 23 <h3 class="no-top">Props</h3> ··· 26 26 <Prop label="max" bind:value={max} /> 27 27 <Prop label="locale">date-fns <code>hy</code></Prop> 28 28 <Prop label="browseWithoutSelecting" bind:value={browseWithoutSelecting} /> 29 + <Prop 30 + label="timePrecision" 31 + bind:value={timePrecision} 32 + values={[null, 'minute', 'second', 'millisecond']}>{timePrecision}</Prop 33 + > 29 34 </div> 30 35 </Split>
+23 -21
src/routes/docs/+page.md
··· 29 29 30 30 ### <a id="props" />Props 31 31 32 - | Prop | Type | Description | 33 - | :----------------------- | :----------- | :---------------------------------------------------------- | 34 - | `value` | Date \| null | Date value | 35 - | `min` | Date | The earliest value the user can select | 36 - | `max` | Date | The latest value the user can select | 37 - | `placeholder` | string | Placeholder used when date value is null | 38 - | `valid` | bool | Whether the text is valid | 39 - | `format` | string | Format string | 40 - | `visible` | bool | Whether the date popup is visible | 41 - | `disabled` | bool | Disable the input | 42 - | `closeOnSelection` | bool | Close the date popup when a date is selected | 43 - | `browseWithoutSelecting` | bool | Wait with updating the date until a value is selected | 44 - | `dynamicPositioning` | bool | Dynamicly postions the date popup to best fit on the screen | 45 - | `locale` | Locale | Locale object for internationalization | 32 + | Prop | Type | Description | 33 + | :----------------------- | :-------------------------------------------- | :---------------------------------------------------------- | 34 + | `value` | Date \| null | Date value | 35 + | `min` | Date | The earliest value the user can select | 36 + | `max` | Date | The latest value the user can select | 37 + | `placeholder` | string | Placeholder used when date value is null | 38 + | `timePrecision` | "minute" \| "second" \| "millisecond" \| null | Show a time picker with the specified precision | 39 + | `valid` | bool | Whether the text is valid | 40 + | `format` | string | Format string | 41 + | `visible` | bool | Whether the date popup is visible | 42 + | `disabled` | bool | Disable the input | 43 + | `closeOnSelection` | bool | Close the date popup when a date is selected | 44 + | `browseWithoutSelecting` | bool | Wait with updating the date until a value is selected | 45 + | `dynamicPositioning` | bool | Dynamicly postions the date popup to best fit on the screen | 46 + | `locale` | Locale | Locale object for internationalization | 46 47 47 48 #### <a id="format-string" />Format string 48 49 ··· 65 66 66 67 ### <a id="datepicker-props" />Props 67 68 68 - | Prop | Type | Description | 69 - | :----------------------- | :----------- | :--------------------------------------------------- | 70 - | `value` | Date \| null | Date value | 71 - | `min` | Date | The earliest year the user can select | 72 - | `max` | Date | The latest year the user can select | 73 - | `locale` | Locale | Locale object for internationalization | 74 - | `browseWithoutSelecting` | bool | Wait with updating the date until a date is selected | 69 + | Prop | Type | Description | 70 + | :----------------------- | :-------------------------------------------- | :--------------------------------------------------- | 71 + | `value` | Date \| null | Date value | 72 + | `min` | Date | The earliest year the user can select | 73 + | `max` | Date | The latest year the user can select | 74 + | `timePrecision` | "minute" \| "second" \| "millisecond" \| null | Show a time picker with the specified precision | 75 + | `locale` | Locale | Locale object for internationalization | 76 + | `browseWithoutSelecting` | bool | Wait with updating the date until a date is selected | 75 77 76 78 ## <a id="internationalization" />Internationalization 77 79
+10 -2
src/routes/prop.svelte
··· 2 2 import DateInput from '$lib/DateInput.svelte' 3 3 4 4 export let value: unknown = undefined 5 + export let values: unknown[] | undefined = undefined 5 6 export let label: string 6 7 let jsonValue = '' 7 8 $: if (value instanceof Object) { ··· 18 19 19 20 <div class="prop"> 20 21 <div class="label">{label}</div> 21 - {#if typeof value === 'string'} 22 + {#if values} 23 + <select bind:value> 24 + {#each values as value} 25 + <option {value}>{String(value)}</option> 26 + {/each} 27 + </select> 28 + {:else if typeof value === 'string'} 22 29 <input type="text" bind:value /> 23 30 {:else if typeof value === 'boolean'} 24 31 <input type="checkbox" bind:checked={value} /> ··· 27 34 bind:value 28 35 min={new Date(new Date().getFullYear() - 20, 0)} 29 36 max={new Date(new Date().getFullYear() + 5, 0)} 37 + dynamicPositioning 30 38 /> 31 39 {:else if value instanceof Object} 32 40 <textarea bind:value={jsonValue} on:input={jsonInput} /> ··· 47 55 .label 48 56 width: 195px 49 57 flex-shrink: 0 50 - input[type='text'], textarea 58 + input[type='text'], textarea, select 51 59 color: var(--foreground) 52 60 background: var(--input-background) 53 61 border: 1px solid rgba(103, 113, 137, 0.3)
+1
svelte.config.js
··· 9 9 vitePreprocess(), 10 10 mdsvex({ 11 11 extensions: ['.md'], 12 + smartypants: false, 12 13 }), 13 14 ], 14 15