noph-ui 0.44.0 → 0.45.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 (135) hide show
  1. package/README.md +10 -1
  2. package/dist/animation.d.ts +17 -0
  3. package/dist/animation.js +48 -0
  4. package/dist/app-bar/AppBar.svelte +280 -0
  5. package/dist/app-bar/AppBar.svelte.d.ts +4 -0
  6. package/dist/app-bar/index.d.ts +1 -0
  7. package/dist/app-bar/index.js +1 -0
  8. package/dist/app-bar/types.d.ts +35 -0
  9. package/dist/app-bar/types.js +1 -0
  10. package/dist/autocomplete/AutoComplete.svelte +10 -11
  11. package/dist/autocomplete/AutoComplete.svelte.d.ts +1 -1
  12. package/dist/autocomplete/types.d.ts +1 -1
  13. package/dist/badge/Badge.svelte +6 -1
  14. package/dist/badge/types.d.ts +0 -1
  15. package/dist/button/Button.svelte +5 -15
  16. package/dist/button/IconButton.svelte +5 -15
  17. package/dist/button/SegmentedButton.svelte +15 -1
  18. package/dist/button/SplitButton.svelte +92 -0
  19. package/dist/button/SplitButton.svelte.d.ts +4 -0
  20. package/dist/button/index.d.ts +1 -0
  21. package/dist/button/index.js +1 -0
  22. package/dist/button/types.d.ts +23 -4
  23. package/dist/card/types.d.ts +1 -1
  24. package/dist/checkbox/Checkbox.svelte +49 -58
  25. package/dist/chip/AssistChip.svelte +5 -5
  26. package/dist/chip/ChipSet.svelte +3 -11
  27. package/dist/chip/FilterChip.svelte +35 -27
  28. package/dist/chip/InputChip.svelte +13 -2
  29. package/dist/chip/SuggestionChip.svelte +29 -0
  30. package/dist/chip/SuggestionChip.svelte.d.ts +4 -0
  31. package/dist/chip/index.d.ts +1 -0
  32. package/dist/chip/index.js +1 -0
  33. package/dist/chip/types.d.ts +24 -9
  34. package/dist/date-picker/Calendar.svelte +1 -1
  35. package/dist/date-picker/DatePickerDialog.svelte +30 -8
  36. package/dist/date-picker/DatePickerDialog.svelte.d.ts +4 -1
  37. package/dist/date-picker/DateRangePicker.svelte +45 -10
  38. package/dist/date-picker/DateRangePicker.svelte.d.ts +4 -1
  39. package/dist/date-picker/DockedDatePicker.svelte +18 -4
  40. package/dist/date-picker/DockedDatePicker.svelte.d.ts +4 -1
  41. package/dist/date-picker/DockedDateTimePicker.svelte +18 -4
  42. package/dist/date-picker/DockedDateTimePicker.svelte.d.ts +4 -1
  43. package/dist/date-picker/SelectionList.svelte +6 -32
  44. package/dist/date-picker/SelectionList.svelte.d.ts +2 -12
  45. package/dist/date-picker/TimeColumn.svelte +4 -29
  46. package/dist/date-picker/exitVisibility.svelte.d.ts +10 -0
  47. package/dist/date-picker/exitVisibility.svelte.js +30 -0
  48. package/dist/date-picker/optionList.d.ts +16 -0
  49. package/dist/date-picker/optionList.js +43 -0
  50. package/dist/date-picker/types.d.ts +11 -4
  51. package/dist/dialog/Dialog.svelte +77 -68
  52. package/dist/dialog/Dialog.svelte.d.ts +3 -3
  53. package/dist/dialog/types.d.ts +5 -3
  54. package/dist/fab/ExtendedFab.svelte +281 -0
  55. package/dist/fab/ExtendedFab.svelte.d.ts +5 -0
  56. package/dist/fab/Fab.svelte +239 -0
  57. package/dist/fab/Fab.svelte.d.ts +5 -0
  58. package/dist/fab/FabMenu.svelte +255 -0
  59. package/dist/fab/FabMenu.svelte.d.ts +7 -0
  60. package/dist/fab/index.d.ts +3 -0
  61. package/dist/fab/index.js +3 -0
  62. package/dist/fab/types.d.ts +58 -0
  63. package/dist/fab/types.js +1 -0
  64. package/dist/icons/ArrowBackIcon.svelte +13 -0
  65. package/dist/icons/ArrowBackIcon.svelte.d.ts +7 -0
  66. package/dist/icons/ChevronDownIcon.svelte +3 -0
  67. package/dist/icons/ChevronDownIcon.svelte.d.ts +26 -0
  68. package/dist/icons/SearchIcon.svelte +15 -0
  69. package/dist/icons/SearchIcon.svelte.d.ts +7 -0
  70. package/dist/icons/index.d.ts +3 -0
  71. package/dist/icons/index.js +3 -0
  72. package/dist/index.d.ts +6 -0
  73. package/dist/index.js +6 -0
  74. package/dist/internal/focus-ring.css +12 -0
  75. package/dist/menu/Menu.svelte +12 -7
  76. package/dist/menu/Menu.svelte.d.ts +2 -2
  77. package/dist/menu/types.d.ts +0 -2
  78. package/dist/navigation-bar/NavigationBar.svelte +66 -0
  79. package/dist/navigation-bar/NavigationBar.svelte.d.ts +4 -0
  80. package/dist/navigation-bar/NavigationBarItem.svelte +261 -0
  81. package/dist/navigation-bar/NavigationBarItem.svelte.d.ts +5 -0
  82. package/dist/navigation-bar/index.d.ts +2 -0
  83. package/dist/navigation-bar/index.js +2 -0
  84. package/dist/navigation-bar/types.d.ts +29 -0
  85. package/dist/navigation-bar/types.js +1 -0
  86. package/dist/navigation-drawer/NavigationDrawer.svelte +128 -76
  87. package/dist/navigation-drawer/NavigationDrawer.svelte.d.ts +4 -1
  88. package/dist/navigation-drawer/NavigationDrawerItem.svelte +10 -3
  89. package/dist/navigation-drawer/types.d.ts +6 -1
  90. package/dist/navigation-rail/NavigationRailItem.svelte +16 -0
  91. package/dist/navigation-rail/types.d.ts +3 -0
  92. package/dist/popover.svelte.d.ts +14 -0
  93. package/dist/popover.svelte.js +31 -0
  94. package/dist/press.svelte.d.ts +19 -0
  95. package/dist/press.svelte.js +45 -0
  96. package/dist/radio/Radio.svelte +26 -7
  97. package/dist/radio/types.d.ts +3 -0
  98. package/dist/search/Search.svelte +472 -0
  99. package/dist/search/Search.svelte.d.ts +5 -0
  100. package/dist/search/index.d.ts +1 -0
  101. package/dist/search/index.js +1 -0
  102. package/dist/search/types.d.ts +39 -0
  103. package/dist/search/types.js +1 -0
  104. package/dist/select/Select.svelte +48 -87
  105. package/dist/sheet/Sheet.svelte +260 -0
  106. package/dist/sheet/Sheet.svelte.d.ts +7 -0
  107. package/dist/sheet/index.d.ts +1 -0
  108. package/dist/sheet/index.js +1 -0
  109. package/dist/sheet/types.d.ts +19 -0
  110. package/dist/sheet/types.js +1 -0
  111. package/dist/slider/Slider.svelte +154 -30
  112. package/dist/slider/types.d.ts +3 -0
  113. package/dist/snackbar/Snackbar.svelte +48 -37
  114. package/dist/snackbar/Snackbar.svelte.d.ts +2 -2
  115. package/dist/snackbar/types.d.ts +3 -2
  116. package/dist/switch/Switch.svelte +51 -20
  117. package/dist/switch/types.d.ts +3 -0
  118. package/dist/tabs/Tab.svelte +8 -12
  119. package/dist/tabs/Tabs.svelte +3 -3
  120. package/dist/text-field/TextField.svelte +2 -2
  121. package/dist/toolbar/Toolbar.svelte +133 -0
  122. package/dist/toolbar/Toolbar.svelte.d.ts +4 -0
  123. package/dist/toolbar/index.d.ts +1 -0
  124. package/dist/toolbar/index.js +1 -0
  125. package/dist/toolbar/types.d.ts +15 -0
  126. package/dist/toolbar/types.js +1 -0
  127. package/dist/tooltip/RichTooltip.svelte +25 -12
  128. package/dist/tooltip/RichTooltip.svelte.d.ts +2 -2
  129. package/dist/tooltip/Tooltip.svelte +22 -11
  130. package/dist/tooltip/Tooltip.svelte.d.ts +4 -1
  131. package/dist/types.d.ts +6 -0
  132. package/dist/types.js +6 -0
  133. package/package.json +13 -13
  134. package/dist/date-picker/enterAnimation.d.ts +0 -1
  135. package/dist/date-picker/enterAnimation.js +0 -10
@@ -0,0 +1,260 @@
1
+ <script lang="ts">
2
+ import { syncOpenEffect } from '../popover.svelte.js'
3
+ import type { SheetProps } from './types.js'
4
+
5
+ let {
6
+ open = $bindable(false),
7
+ modal = true,
8
+ placement = 'bottom',
9
+ handle = true,
10
+ headline,
11
+ action,
12
+ children,
13
+ element = $bindable(),
14
+ onclose,
15
+ ontoggle,
16
+ ...attributes
17
+ }: SheetProps = $props()
18
+
19
+ const uid = $props.id()
20
+ const headlineId = `np-sheet-headline-${uid}`
21
+
22
+ export const show = () => {
23
+ if (!element || element.open) return
24
+ if (modal) element.showModal()
25
+ else element.show()
26
+ }
27
+
28
+ export const close = () => {
29
+ element?.close()
30
+ }
31
+
32
+ syncOpenEffect(
33
+ () => element,
34
+ () => open,
35
+ show,
36
+ close,
37
+ )
38
+ </script>
39
+
40
+ <!--
41
+ A `dialog` rather than a popover. `showModal()` keeps everything outside the sheet inert for as
42
+ long as it is open, including anything added while it is open, and restores focus on close. A
43
+ hand-rolled trap can only inert what exists at the moment it runs.
44
+
45
+ `closedby="any"` is the light dismiss a popover gets for free. Where it is unsupported the click
46
+ handler below covers it: a click on the backdrop is reported against the dialog itself.
47
+ -->
48
+ <dialog
49
+ {...attributes}
50
+ bind:this={element}
51
+ tabindex="-1"
52
+ closedby={modal ? 'any' : 'none'}
53
+ aria-labelledby={headline ? headlineId : attributes['aria-labelledby']}
54
+ class={['np-sheet', `np-sheet-${placement}`, modal && 'np-sheet-modal', attributes.class]}
55
+ onclose={(event) => {
56
+ open = false
57
+ onclose?.(event)
58
+ }}
59
+ ontoggle={(event) => {
60
+ open = event.newState === 'open'
61
+ /*
62
+ * Without this the browser autofocuses the first focusable descendant, which is the close
63
+ * button when `action` puts one in the header, firing its tooltip on open. Focus the sheet
64
+ * itself instead, so nothing is focused that the user didn't choose to interact with.
65
+ */
66
+ if (event.newState === 'open') element?.focus()
67
+ ontoggle?.(event)
68
+ }}
69
+ onclick={(event) => {
70
+ if (modal && event.target === element) close()
71
+ }}
72
+ >
73
+ {#if handle && placement === 'bottom'}
74
+ <div class="np-sheet-handle" aria-hidden="true"></div>
75
+ {/if}
76
+ {#if headline || action}
77
+ <div class="np-sheet-header">
78
+ {#if headline}
79
+ <h2 id={headlineId} class="np-sheet-headline">{headline}</h2>
80
+ {/if}
81
+ {#if action}
82
+ <div class="np-sheet-action">{@render action()}</div>
83
+ {/if}
84
+ </div>
85
+ {/if}
86
+ <div class="np-sheet-content">
87
+ {@render children?.()}
88
+ </div>
89
+ </dialog>
90
+
91
+ <style>
92
+ .np-sheet {
93
+ position: fixed;
94
+ /* Focus lands here on open only to stay off the header action, not as something to show. */
95
+ outline: none;
96
+ /*
97
+ * `showModal()` puts a modal sheet in the top layer, above normal stacking order regardless
98
+ * of z-index. A non-modal sheet (`.show()`) stays in normal flow, so it needs a z-index of
99
+ * its own to clear app bars and other overlays.
100
+ */
101
+ z-index: var(--np-sheet-z-index, 24);
102
+ box-sizing: border-box;
103
+ margin: 0;
104
+ padding: 0;
105
+ border: none;
106
+ max-width: none;
107
+ max-height: none;
108
+ display: flex;
109
+ flex-direction: column;
110
+ overflow: hidden;
111
+ background-color: var(--np-sheet-container-color, var(--np-color-surface-container-low));
112
+ color: var(--np-color-on-surface);
113
+ box-shadow: var(--np-sheet-elevation, var(--np-elevation-1));
114
+ }
115
+
116
+ /* A closed dialog is `display: none` from the user agent, which the enter transition needs. */
117
+ .np-sheet:not([open]) {
118
+ display: none;
119
+ }
120
+
121
+ .np-sheet-bottom,
122
+ .np-sheet-top {
123
+ inset-inline: 0;
124
+ width: 100%;
125
+ max-height: var(--np-sheet-size, 50dvh);
126
+ }
127
+ .np-sheet-bottom {
128
+ inset-block-end: 0;
129
+ inset-block-start: auto;
130
+ border-start-start-radius: var(--np-sheet-shape, var(--np-shape-corner-extra-large));
131
+ border-start-end-radius: var(--np-sheet-shape, var(--np-shape-corner-extra-large));
132
+ }
133
+ .np-sheet-top {
134
+ inset-block-start: 0;
135
+ inset-block-end: auto;
136
+ border-end-start-radius: var(--np-sheet-shape, var(--np-shape-corner-extra-large));
137
+ border-end-end-radius: var(--np-sheet-shape, var(--np-shape-corner-extra-large));
138
+ }
139
+
140
+ .np-sheet-start,
141
+ .np-sheet-end {
142
+ inset-block: 0;
143
+ height: 100dvh;
144
+ width: var(--np-sheet-size, 20rem);
145
+ max-width: 100%;
146
+ }
147
+ .np-sheet-start {
148
+ inset-inline-start: 0;
149
+ inset-inline-end: auto;
150
+ border-start-end-radius: var(--np-sheet-shape, var(--np-shape-corner-large));
151
+ border-end-end-radius: var(--np-sheet-shape, var(--np-shape-corner-large));
152
+ }
153
+ .np-sheet-end {
154
+ inset-inline-end: 0;
155
+ inset-inline-start: auto;
156
+ border-start-start-radius: var(--np-sheet-shape, var(--np-shape-corner-large));
157
+ border-end-start-radius: var(--np-sheet-shape, var(--np-shape-corner-large));
158
+ }
159
+
160
+ .np-sheet-handle {
161
+ flex: none;
162
+ width: 2rem;
163
+ height: 0.25rem;
164
+ margin: 1rem auto 0;
165
+ border-radius: var(--np-shape-corner-full);
166
+ background-color: var(--np-sheet-handle-color, var(--np-color-outline-variant));
167
+ }
168
+
169
+ .np-sheet-header {
170
+ flex: none;
171
+ display: flex;
172
+ align-items: center;
173
+ gap: 0.5rem;
174
+ padding: 1rem 1.5rem 0;
175
+ }
176
+ .np-sheet-headline {
177
+ flex: 1;
178
+ margin: 0;
179
+ font-size: 1.5rem;
180
+ line-height: 2rem;
181
+ font-weight: 400;
182
+ }
183
+ .np-sheet-action {
184
+ flex: none;
185
+ display: flex;
186
+ }
187
+
188
+ .np-sheet-content {
189
+ flex: 1;
190
+ min-height: 0;
191
+ overflow-y: auto;
192
+ overscroll-behavior: contain;
193
+ padding: 1rem 1.5rem 1.5rem;
194
+ }
195
+
196
+ /* `::backdrop` is the scrim, so a modal sheet needs no backdrop element of its own. */
197
+ .np-sheet-modal::backdrop {
198
+ background-color: var(--np-color-scrim);
199
+ opacity: 0.32;
200
+ }
201
+
202
+ /*
203
+ * `display` and `overlay` are discrete properties, so the sheet would appear and vanish without
204
+ * `allow-discrete`. `@starting-style` gives the entry a value to move from.
205
+ */
206
+ @media (prefers-reduced-motion: no-preference) {
207
+ .np-sheet {
208
+ transition:
209
+ translate var(--np-motion-expressive-default-spatial),
210
+ display var(--np-motion-expressive-default-spatial) allow-discrete,
211
+ overlay var(--np-motion-expressive-default-spatial) allow-discrete;
212
+ }
213
+ .np-sheet::backdrop {
214
+ transition:
215
+ opacity var(--np-motion-expressive-fast-effects),
216
+ display var(--np-motion-expressive-fast-effects) allow-discrete,
217
+ overlay var(--np-motion-expressive-fast-effects) allow-discrete;
218
+ }
219
+
220
+ .np-sheet-bottom {
221
+ translate: 0 100%;
222
+ }
223
+ .np-sheet-top {
224
+ translate: 0 -100%;
225
+ }
226
+ .np-sheet-start {
227
+ translate: -100% 0;
228
+ }
229
+ .np-sheet-end {
230
+ translate: 100% 0;
231
+ }
232
+ .np-sheet[open] {
233
+ translate: 0 0;
234
+ }
235
+ @starting-style {
236
+ .np-sheet-bottom[open] {
237
+ translate: 0 100%;
238
+ }
239
+ .np-sheet-top[open] {
240
+ translate: 0 -100%;
241
+ }
242
+ .np-sheet-start[open] {
243
+ translate: -100% 0;
244
+ }
245
+ .np-sheet-end[open] {
246
+ translate: 100% 0;
247
+ }
248
+ }
249
+
250
+ .np-sheet-modal::backdrop {
251
+ opacity: 0;
252
+ }
253
+ .np-sheet-modal[open]::backdrop {
254
+ opacity: 0.32;
255
+ @starting-style {
256
+ opacity: 0;
257
+ }
258
+ }
259
+ }
260
+ </style>
@@ -0,0 +1,7 @@
1
+ import type { SheetProps } from './types.ts';
2
+ declare const Sheet: import("svelte").Component<SheetProps, {
3
+ show: () => void;
4
+ close: () => void;
5
+ }, "element" | "open">;
6
+ type Sheet = ReturnType<typeof Sheet>;
7
+ export default Sheet;
@@ -0,0 +1 @@
1
+ export { default as Sheet } from './Sheet.svelte';
@@ -0,0 +1 @@
1
+ export { default as Sheet } from './Sheet.svelte';
@@ -0,0 +1,19 @@
1
+ import type { Snippet } from 'svelte';
2
+ import type { HTMLDialogAttributes } from 'svelte/elements';
3
+ export interface SheetProps extends Omit<HTMLDialogAttributes, 'open'> {
4
+ /** Bindable. Whether the sheet is showing. */
5
+ open?: boolean;
6
+ /**
7
+ * A modal sheet takes focus and blocks the page behind it; a standard one sits alongside the
8
+ * content and leaves it usable.
9
+ */
10
+ modal?: boolean;
11
+ /** Which edge the sheet is docked to. `'bottom'` is M3's bottom sheet, the sides are side sheets. */
12
+ placement?: 'bottom' | 'top' | 'start' | 'end';
13
+ /** The drag handle M3 puts on a bottom sheet. Ignored on the other edges. */
14
+ handle?: boolean;
15
+ headline?: string;
16
+ /** Trailing action in the header, usually a close button. */
17
+ action?: Snippet;
18
+ element?: HTMLDialogElement;
19
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -1,4 +1,5 @@
1
1
  <script lang="ts">
2
+ import { popoverController, syncOpenEffect } from '../popover.svelte.js'
2
3
  import type { SliderProps } from './types.js'
3
4
 
4
5
  let {
@@ -21,9 +22,14 @@
21
22
  element = $bindable(),
22
23
  inputElement = $bindable(),
23
24
  endInputElement = $bindable(),
25
+ issues,
26
+ 'aria-invalid': ariaInvalid,
24
27
  ...attributes
25
28
  }: SliderProps = $props()
26
29
 
30
+ let hasError = $derived(!!issues?.length)
31
+ const uid = $props.id()
32
+
27
33
  const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v))
28
34
 
29
35
  let span = $derived(max - min || 1)
@@ -109,6 +115,38 @@
109
115
 
110
116
  const DRAG_THRESHOLD = 3
111
117
 
118
+ /*
119
+ * The value label is a `manual` popover rather than a plain absolutely positioned element, so it
120
+ * renders in the top layer and isn't clipped by an ancestor that scrolls or clips overflow, the
121
+ * way a `DemoContainer` or any card with `overflow: hidden` would otherwise cut it off. `manual`
122
+ * rather than `hint`, whose open and close the browser drives on its own schedule; this one has
123
+ * to follow the drag exactly.
124
+ */
125
+ let startLabelElement = $state<HTMLDivElement>()
126
+ let endLabelElement = $state<HTMLDivElement>()
127
+ let startHandleElement = $state<HTMLDivElement>()
128
+ let endHandleElement = $state<HTMLDivElement>()
129
+ let hovered = $state<'start' | 'end' | null>(null)
130
+ let focused = $state<'start' | 'end' | null>(null)
131
+
132
+ /*
133
+ * One handle carries the label at a time, the one being interacted with: a drag wins, then the
134
+ * pointer, then the keyboard. Focus only counts when it did not come from a pointer, since a
135
+ * drag focuses the input and `:focus-visible` does match a range input focused that way, which
136
+ * is why the focus ring is suppressed by `np-pointer-focused` too. Without that gate the label
137
+ * would stay up after a drag for as long as the input keeps focus, however far the pointer went.
138
+ */
139
+ let labelled = $derived(
140
+ disabled ? null : (dragging ?? hovered ?? (pointerFocused ? null : focused)),
141
+ )
142
+
143
+ const bindLabel = (getElement: () => HTMLDivElement | undefined, side: 'start' | 'end') => {
144
+ const controller = popoverController(getElement)
145
+ syncOpenEffect(getElement, () => labelled === side, controller.show, controller.close)
146
+ }
147
+ bindLabel(() => startLabelElement, 'start')
148
+ bindLabel(() => endLabelElement, 'end')
149
+
112
150
  $effect(() => {
113
151
  if (!icon || !activeLaneElement) return
114
152
  const lane = activeLaneElement
@@ -155,7 +193,32 @@
155
193
  }
156
194
  }
157
195
 
196
+ /*
197
+ * Hover is derived from where the pointer actually is rather than from the handle's own
198
+ * mouseenter/mouseleave. A handle is only a few pixels wide, so those events are easy to miss
199
+ * between frames, and while a drag holds pointer capture they are routed to the capturing
200
+ * element and never reach the handle at all, which left the label stuck open. The tolerance
201
+ * gives the thin handle a hit area worth aiming at.
202
+ */
203
+ const HOVER_TOLERANCE = 12
204
+
205
+ /** Distance from the pointer to a handle's box, zero while inside it. */
206
+ const distanceTo = (handle: HTMLDivElement | undefined, e: PointerEvent) => {
207
+ if (!handle) return Infinity
208
+ const r = handle.getBoundingClientRect()
209
+ const dx = Math.max(r.left - e.clientX, 0, e.clientX - r.right)
210
+ const dy = Math.max(r.top - e.clientY, 0, e.clientY - r.bottom)
211
+ return Math.hypot(dx, dy)
212
+ }
213
+
214
+ const updateHover = (e: PointerEvent) => {
215
+ const toStart = distanceTo(startHandleElement, e)
216
+ const toEnd = distanceTo(endHandleElement, e)
217
+ hovered = Math.min(toStart, toEnd) > HOVER_TOLERANCE ? null : toStart <= toEnd ? 'start' : 'end'
218
+ }
219
+
158
220
  const onpointermove = (e: PointerEvent) => {
221
+ updateHover(e)
159
222
  if (!dragging) return
160
223
  if (!tracking) {
161
224
  const d = pointerOrigin
@@ -195,6 +258,7 @@
195
258
  tracking = false
196
259
  pointerOrigin = null
197
260
  element?.releasePointerCapture(e.pointerId)
261
+ updateHover(e)
198
262
  input?.dispatchEvent(new Event('change', { bubbles: true }))
199
263
  }
200
264
 
@@ -215,6 +279,7 @@
215
279
  dragging && 'np-dragging',
216
280
  tracking && 'np-tracking',
217
281
  pointerFocused && 'np-pointer-focused',
282
+ hasError && !disabled && 'np-error',
218
283
  attributes.class,
219
284
  ]}
220
285
  role="presentation"
@@ -222,8 +287,13 @@
222
287
  {onpointermove}
223
288
  onpointerup={endDrag}
224
289
  onpointercancel={endDrag}
290
+ onpointerleave={() => (hovered = null)}
225
291
  onkeydown={() => (pointerFocused = false)}
226
- onfocusout={() => (pointerFocused = false)}
292
+ onfocusin={(event) => (focused = event.target === endInputElement ? 'end' : 'start')}
293
+ onfocusout={() => {
294
+ focused = null
295
+ pointerFocused = false
296
+ }}
227
297
  >
228
298
  <div class="np-slider-track" bind:this={trackElement}>
229
299
  {#if hasStartStop}
@@ -247,21 +317,37 @@
247
317
  <span class={['np-slider-tick', isActive(t) && 'np-on-active']} style="--_t:{t}"></span>
248
318
  {/each}
249
319
 
250
- <div class="np-slider-handle np-slider-handle-start">
320
+ <div
321
+ bind:this={startHandleElement}
322
+ class="np-slider-handle np-slider-handle-start"
323
+ style="anchor-name: --np-slider-{uid}-start"
324
+ ></div>
325
+ {#if labeled}
326
+ <div
327
+ bind:this={startLabelElement}
328
+ popover="manual"
329
+ class="np-slider-label-anchor"
330
+ style="position-anchor: --np-slider-{uid}-start"
331
+ >
332
+ <div class="np-slider-label">{labelFor(range ? Math.min(lo, hi) : lo)}</div>
333
+ </div>
334
+ {/if}
335
+ {#if range}
336
+ <div
337
+ bind:this={endHandleElement}
338
+ class="np-slider-handle np-slider-handle-end"
339
+ style="anchor-name: --np-slider-{uid}-end"
340
+ ></div>
251
341
  {#if labeled}
252
- <div class="np-slider-label-anchor">
253
- <div class="np-slider-label">{labelFor(range ? Math.min(lo, hi) : lo)}</div>
342
+ <div
343
+ bind:this={endLabelElement}
344
+ popover="manual"
345
+ class="np-slider-label-anchor"
346
+ style="position-anchor: --np-slider-{uid}-end"
347
+ >
348
+ <div class="np-slider-label">{labelFor(Math.max(lo, hi))}</div>
254
349
  </div>
255
350
  {/if}
256
- </div>
257
- {#if range}
258
- <div class="np-slider-handle np-slider-handle-end">
259
- {#if labeled}
260
- <div class="np-slider-label-anchor">
261
- <div class="np-slider-label">{labelFor(Math.max(lo, hi))}</div>
262
- </div>
263
- {/if}
264
- </div>
265
351
  {/if}
266
352
  </div>
267
353
 
@@ -277,6 +363,7 @@
277
363
  {disabled}
278
364
  aria-orientation={orientation}
279
365
  aria-valuetext={format ? labelFor(lo) : attributes['aria-valuetext']}
366
+ aria-invalid={hasError ? 'true' : ariaInvalid}
280
367
  />
281
368
  {#if range}
282
369
  <input
@@ -377,6 +464,14 @@
377
464
  background: var(--np-slider-active-track-color, var(--np-color-primary));
378
465
  }
379
466
 
467
+ .np-error {
468
+ --np-slider-active-track-color: var(--np-color-error);
469
+ --np-slider-handle-color: var(--np-color-error);
470
+ --np-slider-active-stop-color: var(--np-color-on-error);
471
+ --np-slider-label-container-color: var(--np-color-error);
472
+ --np-slider-label-text-color: var(--np-color-on-error);
473
+ }
474
+
380
475
  .np-slider-inactive-start {
381
476
  inset-inline-start: 0;
382
477
  inset-inline-end: calc(100% - var(--_i1));
@@ -502,15 +597,56 @@
502
597
  block-size var(--np-motion-expressive-fast-effects);
503
598
  }
504
599
 
505
- .np-slider-label-anchor {
506
- position: absolute;
507
- inset-block-end: calc(100% + 0.375rem);
508
- inset-inline: 0;
509
- display: flex;
600
+ /*
601
+ * A `manual` popover rather than a plain absolutely positioned element: it renders in the top
602
+ * layer, anchored to the handle by `position-anchor`/`position-area` rather than by an inset
603
+ * computed from the handle's own box, so it isn't clipped by an ancestor that scrolls or hides
604
+ * overflow. It stays a wrapper around the visible label rather than the popover itself carrying
605
+ * the styling, so it can keep resolving `position-area` in the slider's own writing mode while
606
+ * the label inside always renders horizontally, the same split the previous absolute-positioned
607
+ * version needed for a vertical slider's rotated writing mode.
608
+ */
609
+ .np-slider-label-anchor[popover] {
610
+ position-area: block-start;
611
+ justify-self: anchor-center;
612
+ margin: 0 0 0.375rem 0;
613
+ border: none;
614
+ background: none;
615
+ padding: 0;
510
616
  justify-content: center;
511
617
  pointer-events: none;
512
618
  }
513
619
 
620
+ /*
621
+ * `display` only while open. Setting it on the base rule would beat the user agent's
622
+ * `display: none` for a closed popover, because author styles win over user agent styles
623
+ * whatever the specificity, leaving the label on screen the whole time.
624
+ */
625
+ .np-slider-label-anchor:popover-open {
626
+ display: flex;
627
+ opacity: 1;
628
+ scale: 1;
629
+ }
630
+
631
+ @media (prefers-reduced-motion: no-preference) {
632
+ .np-slider-label-anchor:popover-open {
633
+ animation:
634
+ fadeIn var(--np-motion-expressive-fast-effects),
635
+ scaleIn var(--np-motion-expressive-fast-spatial);
636
+ }
637
+ }
638
+
639
+ @keyframes fadeIn {
640
+ from {
641
+ opacity: 0;
642
+ }
643
+ }
644
+ @keyframes scaleIn {
645
+ from {
646
+ scale: 0.8;
647
+ }
648
+ }
649
+
514
650
  .np-slider-label {
515
651
  flex: none;
516
652
  writing-mode: horizontal-tb;
@@ -526,18 +662,6 @@
526
662
  font-weight: 500;
527
663
  text-align: center;
528
664
  white-space: nowrap;
529
- opacity: 0;
530
- transition: opacity var(--np-motion-expressive-fast-effects);
531
- }
532
-
533
- .np-slider.np-labeled:hover .np-slider-label,
534
- .np-slider.np-labeled.np-dragging .np-slider-label,
535
- .np-slider.np-labeled:has(.np-slider-input:focus-visible) .np-slider-label {
536
- opacity: 1;
537
- }
538
-
539
- .np-slider.np-disabled .np-slider-label {
540
- opacity: 0;
541
665
  }
542
666
 
543
667
  .np-slider-input {
@@ -20,4 +20,7 @@ export interface SliderProps extends Omit<HTMLInputAttributes, 'type' | 'value'
20
20
  element?: HTMLDivElement;
21
21
  inputElement?: HTMLInputElement;
22
22
  endInputElement?: HTMLInputElement;
23
+ issues?: {
24
+ message: string;
25
+ }[];
23
26
  }