noph-ui 0.46.0 → 0.47.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.
Files changed (66) hide show
  1. package/README.md +2 -1
  2. package/dist/button/ButtonGroup.svelte +1 -3
  3. package/dist/date-picker/DockedDatePicker.svelte +2 -3
  4. package/dist/date-picker/DockedDateTimePicker.svelte +2 -3
  5. package/dist/date-picker/dateUtils.d.ts +2 -13
  6. package/dist/date-picker/dateUtils.js +4 -66
  7. package/dist/date-picker/formatters.d.ts +4 -0
  8. package/dist/date-picker/formatters.js +26 -0
  9. package/dist/date-picker/timeUtils.d.ts +34 -0
  10. package/dist/date-picker/timeUtils.js +139 -0
  11. package/dist/dialog/Dialog.svelte +4 -2
  12. package/dist/dialog/types.d.ts +1 -0
  13. package/dist/fab/FabMenu.svelte +1 -2
  14. package/dist/form-reset.d.ts +2 -1
  15. package/dist/form-reset.js +2 -2
  16. package/dist/icons/KeyboardIcon.svelte +5 -0
  17. package/dist/icons/KeyboardIcon.svelte.d.ts +26 -0
  18. package/dist/icons/ScheduleIcon.svelte +5 -0
  19. package/dist/icons/ScheduleIcon.svelte.d.ts +26 -0
  20. package/dist/icons/index.d.ts +2 -0
  21. package/dist/icons/index.js +2 -0
  22. package/dist/index.d.ts +1 -0
  23. package/dist/index.js +1 -0
  24. package/dist/keyboard-nav.js +3 -2
  25. package/dist/list/index.d.ts +1 -0
  26. package/dist/list/index.js +1 -0
  27. package/dist/media.d.ts +4 -0
  28. package/dist/media.js +4 -0
  29. package/dist/menu/Menu.svelte +2 -1
  30. package/dist/progress/CircularProgress.svelte +9 -9
  31. package/dist/progress/LinearProgress.svelte +1 -2
  32. package/dist/ripple/Ripple.svelte +7 -8
  33. package/dist/search/Search.svelte +14 -4
  34. package/dist/search/Search.svelte.d.ts +1 -1
  35. package/dist/select/Select.svelte +9 -10
  36. package/dist/select/VirtualList.svelte +12 -13
  37. package/dist/sheet/Sheet.svelte +4 -1
  38. package/dist/sheet/types.d.ts +1 -0
  39. package/dist/slider/Slider.svelte +5 -6
  40. package/dist/tabs/Tabs.svelte +21 -14
  41. package/dist/time-picker/ClockDial.svelte +528 -0
  42. package/dist/time-picker/ClockDial.svelte.d.ts +4 -0
  43. package/dist/time-picker/DockedTimePicker.svelte +378 -0
  44. package/dist/time-picker/DockedTimePicker.svelte.d.ts +7 -0
  45. package/dist/time-picker/PeriodSelector.svelte +156 -0
  46. package/dist/time-picker/PeriodSelector.svelte.d.ts +4 -0
  47. package/dist/time-picker/TimeInputs.svelte +233 -0
  48. package/dist/time-picker/TimeInputs.svelte.d.ts +4 -0
  49. package/dist/time-picker/TimePickerDialog.svelte +229 -0
  50. package/dist/time-picker/TimePickerDialog.svelte.d.ts +7 -0
  51. package/dist/time-picker/TimePickerPanel.svelte +203 -0
  52. package/dist/time-picker/TimePickerPanel.svelte.d.ts +4 -0
  53. package/dist/time-picker/TimeSelectors.svelte +157 -0
  54. package/dist/time-picker/TimeSelectors.svelte.d.ts +4 -0
  55. package/dist/time-picker/index.d.ts +4 -0
  56. package/dist/time-picker/index.js +4 -0
  57. package/dist/time-picker/timePickerState.svelte.d.ts +27 -0
  58. package/dist/time-picker/timePickerState.svelte.js +63 -0
  59. package/dist/time-picker/types.d.ts +148 -0
  60. package/dist/time-picker/types.js +1 -0
  61. package/dist/tooltip/Tooltip.svelte +2 -4
  62. package/dist/types.d.ts +1 -0
  63. package/dist/types.js +1 -0
  64. package/dist/validity.d.ts +2 -0
  65. package/dist/validity.js +4 -0
  66. package/package.json +1 -1
@@ -0,0 +1,378 @@
1
+ <script lang="ts">
2
+ import Button from '../button/Button.svelte'
3
+ import IconButton from '../button/IconButton.svelte'
4
+ import {
5
+ formatMinutes,
6
+ getTimePattern,
7
+ isMinuteWithin,
8
+ minutesOfDay,
9
+ parseISOTime,
10
+ parseTimeInput,
11
+ toISOTime,
12
+ uses12HourClock,
13
+ } from '../date-picker/timeUtils.js'
14
+ import { formReset } from '../form-reset.js'
15
+ import KeyboardIcon from '../icons/KeyboardIcon.svelte'
16
+ import ScheduleIcon from '../icons/ScheduleIcon.svelte'
17
+ import Menu from '../menu/Menu.svelte'
18
+ import { syncOpenEffect } from '../popover.svelte.js'
19
+ import TextField from '../text-field/TextField.svelte'
20
+ import { onMount, tick } from 'svelte'
21
+ import TimePickerPanel from './TimePickerPanel.svelte'
22
+ import { timePickerState } from './timePickerState.svelte.js'
23
+ import type { DockedTimePickerProps, ISOTime, TimePickerMode } from './types.js'
24
+
25
+ let {
26
+ value = $bindable(),
27
+ defaultValue,
28
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars -- pulled out of the rest props on purpose
29
+ type,
30
+ 'aria-invalid': ariaInvalid,
31
+ style,
32
+ open = $bindable(false),
33
+ element = $bindable(),
34
+ mode = $bindable('dial'),
35
+ modeToggle = true,
36
+ min,
37
+ max,
38
+ minuteStep = 1,
39
+ hour12,
40
+ locale,
41
+ isTimeEnabled,
42
+ label = 'Time',
43
+ supportingText,
44
+ issues,
45
+ variant = 'outlined',
46
+ name,
47
+ form,
48
+ required,
49
+ disabled,
50
+ readonly,
51
+ noAsterisk,
52
+ autocomplete = 'off',
53
+ cancelLabel = 'Cancel',
54
+ confirmLabel = 'OK',
55
+ hourLabel = 'Hour',
56
+ minuteLabel = 'Minute',
57
+ dayPeriodLabel = 'AM or PM',
58
+ amLabel,
59
+ pmLabel,
60
+ selectHourLabel = 'Select hour',
61
+ selectMinuteLabel = 'Select minute',
62
+ hourOptionLabel,
63
+ minuteOptionLabel,
64
+ dialModeLabel = 'Switch to dial mode',
65
+ inputModeLabel = 'Switch to text input mode',
66
+ openPickerLabel = 'Show time picker',
67
+ invalidTimeMessage = 'Enter a valid time.',
68
+ onchange,
69
+ oninput,
70
+ onblur,
71
+ ...attributes
72
+ }: DockedTimePickerProps = $props()
73
+
74
+ const ISO_PATTERN = 'HH:mm'
75
+
76
+ const uid = $props.id()
77
+
78
+ let menuElement = $state<HTMLDivElement>()
79
+ let inputElement = $state<HTMLInputElement | HTMLTextAreaElement>()
80
+ let valueInput = $state<HTMLInputElement>()
81
+ let mounted = $state(false)
82
+
83
+ let typed = $state.raw<{ source: ISOTime | undefined; text: string } | undefined>()
84
+ let invalidText = $state<string | undefined>()
85
+
86
+ onMount(() => {
87
+ mounted = true
88
+ })
89
+
90
+ let localeKnown = $derived(locale !== undefined || mounted)
91
+ let clock12 = $derived(hour12 ?? (localeKnown ? uses12HourClock(locale) : false))
92
+ let pattern = $derived(localeKnown ? getTimePattern(locale, clock12) : ISO_PATTERN)
93
+
94
+ let minMinutes = $derived(parseISOTime(min))
95
+ let maxMinutes = $derived(parseISOTime(max))
96
+
97
+ let committed = $derived(parseISOTime(value ?? defaultValue))
98
+ let current = $derived<ISOTime | undefined>(
99
+ committed === undefined ? undefined : toISOTime(committed),
100
+ )
101
+
102
+ const pending = timePickerState({
103
+ minuteStep: () => minuteStep,
104
+ min: () => minMinutes,
105
+ max: () => maxMinutes,
106
+ isTimeEnabled: () => isTimeEnabled,
107
+ })
108
+
109
+ const formatValue = () => {
110
+ if (committed === undefined) return ''
111
+ return localeKnown ? formatMinutes(committed, locale, clock12) : toISOTime(committed)
112
+ }
113
+
114
+ let text = $derived(typed && typed.source === current ? typed.text : formatValue())
115
+ let inputInvalid = $derived(invalidText !== undefined && invalidText === text)
116
+
117
+ const usableTime = (entry: string) => {
118
+ const parsed = parseTimeInput(entry, locale, clock12)
119
+ if (parsed === undefined) return undefined
120
+ if (!isMinuteWithin(parsed, minMinutes, maxMinutes)) return undefined
121
+ return (isTimeEnabled?.(parsed) ?? true) ? parsed : undefined
122
+ }
123
+
124
+ const commit = (next: ISOTime | undefined) => {
125
+ if (current === next) return
126
+ value = next
127
+ onchange?.(next)
128
+ }
129
+
130
+ const restoreDefault = () => {
131
+ const fallback = parseISOTime(defaultValue)
132
+ const next = fallback === undefined ? undefined : toISOTime(fallback)
133
+ if (valueInput) valueInput.value = next ?? ''
134
+ queueMicrotask(() => {
135
+ typed = undefined
136
+ invalidText = undefined
137
+ value = next
138
+ })
139
+ }
140
+
141
+ const isShowing = () => !!menuElement?.matches(':popover-open')
142
+
143
+ const openPicker = () => {
144
+ if (isShowing()) return
145
+ if (disabled || readonly) {
146
+ open = false
147
+ return
148
+ }
149
+ menuElement?.showPopover()
150
+ }
151
+
152
+ export const show = () => openPicker()
153
+
154
+ export const close = () => {
155
+ if (isShowing()) menuElement?.hidePopover()
156
+ }
157
+
158
+ syncOpenEffect(
159
+ () => menuElement,
160
+ () => open,
161
+ show,
162
+ close,
163
+ )
164
+
165
+ const focusPanel = async () => {
166
+ await tick()
167
+ if (mode === 'input') {
168
+ menuElement?.querySelector<HTMLInputElement>('.np-time-input input')?.focus()
169
+ return
170
+ }
171
+ menuElement?.querySelector<HTMLElement>('.np-clock-dial-option[tabindex="0"]')?.focus()
172
+ }
173
+
174
+ const closePicker = (restoreFocus = true) => {
175
+ menuElement?.hidePopover()
176
+ if (restoreFocus) element?.querySelector<HTMLElement>('input')?.focus()
177
+ }
178
+
179
+ const setMode = async (next: TimePickerMode) => {
180
+ if (mode === next) return
181
+ mode = next
182
+ await focusPanel()
183
+ }
184
+
185
+ const confirm = () => {
186
+ typed = undefined
187
+ invalidText = undefined
188
+ commit(toISOTime(pending.minutes))
189
+ closePicker()
190
+ }
191
+
192
+ const handleInput = (event: Event) => {
193
+ const next = (event.currentTarget as HTMLInputElement).value
194
+ const parsed = usableTime(next)
195
+ commit(parsed === undefined ? undefined : toISOTime(parsed))
196
+ typed = { source: current, text: next }
197
+ invalidText = undefined
198
+ }
199
+
200
+ const handleBlur = () => {
201
+ if (!text.trim()) {
202
+ invalidText = undefined
203
+ return
204
+ }
205
+ const parsed = usableTime(text)
206
+ invalidText = parsed === undefined ? text : undefined
207
+ if (parsed !== undefined) typed = undefined
208
+ }
209
+
210
+ $effect(() => {
211
+ const input = inputElement
212
+ if (!input) return
213
+ const unusable = text.trim() !== '' && usableTime(text) === undefined
214
+ input.setCustomValidity(unusable ? invalidTimeMessage : '')
215
+ })
216
+ </script>
217
+
218
+ <TextField
219
+ {...attributes}
220
+ class={['np-docked-time-picker', attributes.class]}
221
+ style={`anchor-name:--${uid};${style ?? ''}`}
222
+ {label}
223
+ {variant}
224
+ {issues}
225
+ {disabled}
226
+ {readonly}
227
+ {required}
228
+ {noAsterisk}
229
+ {autocomplete}
230
+ supportingText={supportingText ?? pattern}
231
+ bind:value={text}
232
+ bind:element
233
+ bind:inputElement
234
+ aria-invalid={inputInvalid || issues?.length || ariaInvalid === true || ariaInvalid === 'true'
235
+ ? 'true'
236
+ : undefined}
237
+ oninput={(event) => {
238
+ handleInput(event)
239
+ oninput?.(event)
240
+ }}
241
+ onblur={(event) => {
242
+ handleBlur()
243
+ onblur?.(event)
244
+ }}
245
+ >
246
+ {#snippet end()}
247
+ <IconButton
248
+ type="button"
249
+ aria-label={openPickerLabel}
250
+ aria-expanded={open}
251
+ aria-haspopup="dialog"
252
+ aria-controls={open ? `${uid}-picker` : undefined}
253
+ {disabled}
254
+ onclick={() => {
255
+ if (open) closePicker()
256
+ else openPicker()
257
+ }}
258
+ >
259
+ <ScheduleIcon />
260
+ </IconButton>
261
+ {/snippet}
262
+ </TextField>
263
+ <input
264
+ bind:this={valueInput}
265
+ {@attach formReset(restoreDefault)}
266
+ class="np-docked-time-picker-value"
267
+ type="hidden"
268
+ value={current ?? ''}
269
+ {name}
270
+ {form}
271
+ {disabled}
272
+ />
273
+
274
+ <Menu
275
+ id="{uid}-picker"
276
+ role="dialog"
277
+ aria-label={label}
278
+ aria-modal="false"
279
+ class="np-docked-time-picker-menu"
280
+ style={`position-anchor:--${uid};`}
281
+ --np-menu-justify-self="none"
282
+ --np-menu-position-area="bottom span-right"
283
+ --np-menu-over-anchor-position-area="span-all span-right"
284
+ --np-menu-margin="0"
285
+ --np-menu-container-color="var(--np-docked-time-picker-container-color, var(--np-color-surface-container-high))"
286
+ --np-menu-container-shape="var(--np-docked-time-picker-container-shape, var(--np-shape-corner-large))"
287
+ anchor={element}
288
+ bind:element={menuElement}
289
+ bind:open
290
+ ontoggle={({ newState }) => {
291
+ if (newState === 'open') {
292
+ pending.reset(committed ?? minutesOfDay(new Date()))
293
+ focusPanel()
294
+ }
295
+ }}
296
+ >
297
+ {#if open}
298
+ <div class="np-docked-time-picker-container">
299
+ <TimePickerPanel
300
+ state={pending}
301
+ {mode}
302
+ hour12={clock12}
303
+ {minuteStep}
304
+ min={minMinutes}
305
+ max={maxMinutes}
306
+ {locale}
307
+ {isTimeEnabled}
308
+ {invalidTimeMessage}
309
+ {hourLabel}
310
+ {minuteLabel}
311
+ {dayPeriodLabel}
312
+ {amLabel}
313
+ {pmLabel}
314
+ {selectHourLabel}
315
+ {selectMinuteLabel}
316
+ {hourOptionLabel}
317
+ {minuteOptionLabel}
318
+ />
319
+
320
+ <div class="np-docked-time-picker-actions">
321
+ {#if modeToggle}
322
+ <IconButton
323
+ type="button"
324
+ aria-label={mode === 'dial' ? inputModeLabel : dialModeLabel}
325
+ onclick={() => setMode(mode === 'dial' ? 'input' : 'dial')}
326
+ >
327
+ {#if mode === 'dial'}
328
+ <KeyboardIcon />
329
+ {:else}
330
+ <ScheduleIcon />
331
+ {/if}
332
+ </IconButton>
333
+ {:else}
334
+ <span></span>
335
+ {/if}
336
+ <div class="np-docked-time-picker-confirm">
337
+ <Button type="button" variant="text" onclick={() => closePicker()}>{cancelLabel}</Button>
338
+ <Button type="button" variant="text" disabled={!pending.usable} onclick={confirm}>
339
+ {confirmLabel}
340
+ </Button>
341
+ </div>
342
+ </div>
343
+ </div>
344
+ {/if}
345
+ </Menu>
346
+
347
+ <style>
348
+ :global(.np-docked-time-picker-menu .np-menu) {
349
+ padding: 0;
350
+ }
351
+
352
+ .np-docked-time-picker-container {
353
+ box-sizing: border-box;
354
+ display: flex;
355
+ flex-direction: column;
356
+ max-width: 100%;
357
+ padding: 1.5rem;
358
+ color: var(--np-color-on-surface);
359
+ }
360
+
361
+ .np-docked-time-picker-actions {
362
+ display: flex;
363
+ justify-content: space-between;
364
+ align-items: center;
365
+ gap: 0.5rem;
366
+ margin-top: 1.5rem;
367
+ }
368
+
369
+ .np-docked-time-picker-confirm {
370
+ display: flex;
371
+ align-items: center;
372
+ gap: 0.5rem;
373
+ }
374
+
375
+ :global(.np-docked-time-picker-actions .np-button) {
376
+ height: 2.25rem;
377
+ }
378
+ </style>
@@ -0,0 +1,7 @@
1
+ import type { DockedTimePickerProps } from './types.ts';
2
+ declare const DockedTimePicker: import("svelte").Component<DockedTimePickerProps, {
3
+ show: () => void;
4
+ close: () => void;
5
+ }, "element" | "value" | "open" | "mode">;
6
+ type DockedTimePicker = ReturnType<typeof DockedTimePicker>;
7
+ export default DockedTimePicker;
@@ -0,0 +1,156 @@
1
+ <script lang="ts">
2
+ import Ripple from '../ripple/Ripple.svelte'
3
+ import type { PeriodSelectorProps } from './types.js'
4
+
5
+ let {
6
+ isPm,
7
+ orientation = 'vertical',
8
+ disabled = false,
9
+ amDisabled = false,
10
+ pmDisabled = false,
11
+ label,
12
+ amLabel,
13
+ pmLabel,
14
+ onselect,
15
+ ...attributes
16
+ }: PeriodSelectorProps = $props()
17
+
18
+ const periods = $derived([
19
+ { pm: false, text: amLabel, unreachable: amDisabled },
20
+ { pm: true, text: pmLabel, unreachable: pmDisabled },
21
+ ])
22
+ </script>
23
+
24
+ <div
25
+ {...attributes}
26
+ role="radiogroup"
27
+ aria-label={label}
28
+ aria-disabled={disabled || undefined}
29
+ class={['np-period-selector', orientation, attributes.class]}
30
+ >
31
+ {#each periods as period (period.pm)}
32
+ <button
33
+ type="button"
34
+ role="radio"
35
+ disabled={disabled || period.unreachable}
36
+ aria-checked={isPm === period.pm}
37
+ class={['np-period-option', isPm === period.pm && 'selected']}
38
+ onclick={() => onselect?.(period.pm)}
39
+ >
40
+ {period.text}
41
+ {#if !disabled && !period.unreachable}
42
+ <Ripple />
43
+ {/if}
44
+ </button>
45
+ {/each}
46
+ </div>
47
+
48
+ <style>
49
+ .np-period-selector {
50
+ display: flex;
51
+ box-sizing: border-box;
52
+ overflow: hidden;
53
+ border-radius: var(
54
+ --np-time-picker-period-selector-container-shape,
55
+ var(--np-shape-corner-small)
56
+ );
57
+ border: 1px solid var(--np-time-picker-period-selector-outline-color, var(--np-color-outline));
58
+ }
59
+
60
+ .np-period-selector.vertical {
61
+ flex-direction: column;
62
+ width: 3.25rem;
63
+ height: 5rem;
64
+ flex: none;
65
+ }
66
+
67
+ .np-period-selector.horizontal {
68
+ flex-direction: row;
69
+ width: 13.5rem;
70
+ height: 2.375rem;
71
+ flex: none;
72
+ }
73
+
74
+ /* One shared hairline between the two halves rather than a border on each. */
75
+ .np-period-selector.vertical .np-period-option + .np-period-option {
76
+ border-block-start: 1px solid
77
+ var(--np-time-picker-period-selector-outline-color, var(--np-color-outline));
78
+ }
79
+
80
+ .np-period-selector.horizontal .np-period-option + .np-period-option {
81
+ border-inline-start: 1px solid
82
+ var(--np-time-picker-period-selector-outline-color, var(--np-color-outline));
83
+ }
84
+
85
+ .np-period-option {
86
+ font: inherit;
87
+ position: relative;
88
+ display: flex;
89
+ flex: 1;
90
+ align-items: center;
91
+ justify-content: center;
92
+ box-sizing: border-box;
93
+ margin: 0;
94
+ padding: 0;
95
+ border: 0;
96
+ min-width: 0;
97
+ cursor: pointer;
98
+ background: none;
99
+ -webkit-tap-highlight-color: transparent;
100
+ font-size: 1rem;
101
+ line-height: 1.5rem;
102
+ font-weight: 500;
103
+ color: var(
104
+ --np-time-picker-period-selector-unselected-label-color,
105
+ var(--np-color-on-surface-variant)
106
+ );
107
+ }
108
+
109
+ .np-period-option.selected {
110
+ background-color: var(
111
+ --np-time-picker-period-selector-selected-container-color,
112
+ var(--np-color-tertiary-container)
113
+ );
114
+ color: var(
115
+ --np-time-picker-period-selector-selected-label-color,
116
+ var(--np-color-on-tertiary-container)
117
+ );
118
+ --np-ripple-hover-color: var(
119
+ --np-time-picker-period-selector-selected-label-color,
120
+ var(--np-color-on-tertiary-container)
121
+ );
122
+ --np-ripple-pressed-color: var(
123
+ --np-time-picker-period-selector-selected-label-color,
124
+ var(--np-color-on-tertiary-container)
125
+ );
126
+ }
127
+
128
+ .np-period-option:disabled {
129
+ cursor: default;
130
+ color: color-mix(in srgb, var(--np-color-on-surface) 38%, transparent);
131
+ }
132
+
133
+ .np-period-option:focus-visible {
134
+ outline: 3px solid var(--np-color-secondary);
135
+ outline-offset: -3px;
136
+ }
137
+
138
+ @media (prefers-reduced-motion: no-preference) {
139
+ .np-period-option {
140
+ transition:
141
+ background-color var(--np-motion-expressive-default-effects),
142
+ color var(--np-motion-expressive-default-effects);
143
+ }
144
+ }
145
+
146
+ @media (forced-colors: active) {
147
+ .np-period-option.selected {
148
+ background-color: Highlight;
149
+ color: HighlightText;
150
+ }
151
+
152
+ .np-period-option:disabled {
153
+ color: GrayText;
154
+ }
155
+ }
156
+ </style>
@@ -0,0 +1,4 @@
1
+ import type { PeriodSelectorProps } from './types.ts';
2
+ declare const PeriodSelector: import("svelte").Component<PeriodSelectorProps, {}, "">;
3
+ type PeriodSelector = ReturnType<typeof PeriodSelector>;
4
+ export default PeriodSelector;