noph-ui 0.51.2 → 0.53.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 (187) hide show
  1. package/dist/animation.d.ts +14 -1
  2. package/dist/animation.js +55 -2
  3. package/dist/app-bar/AppBar.svelte +71 -7
  4. package/dist/app-bar/types.d.ts +6 -0
  5. package/dist/autocomplete/AutoComplete.svelte +6 -31
  6. package/dist/autocomplete/AutoComplete.svelte.d.ts +1 -1
  7. package/dist/badge/Badge.svelte +12 -4
  8. package/dist/badge/description.d.ts +4 -0
  9. package/dist/badge/description.js +4 -0
  10. package/dist/button/Button.svelte +53 -30
  11. package/dist/button/ButtonGroup.svelte +139 -19
  12. package/dist/button/ButtonGroup.svelte.d.ts +1 -1
  13. package/dist/button/IconButton.svelte +51 -33
  14. package/dist/button/SegmentedButton.svelte +52 -15
  15. package/dist/button/SplitButton.svelte +77 -3
  16. package/dist/button/SplitButton.svelte.d.ts +1 -1
  17. package/dist/button/buttonGroup.d.ts +0 -4
  18. package/dist/button/buttonGroup.js +0 -9
  19. package/dist/button/groupContext.d.ts +10 -0
  20. package/dist/button/groupContext.js +4 -0
  21. package/dist/button/types.d.ts +19 -0
  22. package/dist/card/Card.svelte +3 -19
  23. package/dist/card/types.d.ts +12 -2
  24. package/dist/carousel/Carousel.svelte +77 -14
  25. package/dist/carousel/CarouselItem.svelte +26 -14
  26. package/dist/carousel/carouselMorph.js +1 -1
  27. package/dist/carousel/carouselStrategy.js +25 -27
  28. package/dist/checkbox/Checkbox.svelte +6 -8
  29. package/dist/chip/ChipSet.svelte +28 -2
  30. package/dist/chip/FilterChip.svelte +33 -17
  31. package/dist/chip/InputChip.svelte +127 -26
  32. package/dist/chip/SuggestionChip.svelte +6 -22
  33. package/dist/chip/SuggestionChip.svelte.d.ts +1 -2
  34. package/dist/chip/types.d.ts +8 -1
  35. package/dist/date-picker/Calendar.svelte +34 -31
  36. package/dist/date-picker/Calendar.svelte.d.ts +2 -0
  37. package/dist/date-picker/CalendarHeader.svelte +84 -62
  38. package/dist/date-picker/CalendarHeader.svelte.d.ts +3 -0
  39. package/dist/date-picker/DatePickerDialog.svelte +135 -75
  40. package/dist/date-picker/DatePickerDialog.svelte.d.ts +4 -3
  41. package/dist/date-picker/DateRangePicker.svelte +227 -78
  42. package/dist/date-picker/DateRangePicker.svelte.d.ts +3 -3
  43. package/dist/date-picker/DockedDatePicker.svelte +76 -457
  44. package/dist/date-picker/DockedDatePicker.svelte.d.ts +3 -3
  45. package/dist/date-picker/DockedDateTimePicker.svelte +128 -518
  46. package/dist/date-picker/DockedDateTimePicker.svelte.d.ts +3 -3
  47. package/dist/date-picker/DockedPickerField.svelte +221 -0
  48. package/dist/date-picker/DockedPickerField.svelte.d.ts +9 -0
  49. package/dist/date-picker/MonthPanel.svelte +291 -0
  50. package/dist/date-picker/MonthPanel.svelte.d.ts +12 -0
  51. package/dist/date-picker/SelectionList.svelte +7 -6
  52. package/dist/date-picker/SelectionList.svelte.d.ts +1 -0
  53. package/dist/date-picker/TimeColumn.svelte +2 -6
  54. package/dist/date-picker/TimeColumn.svelte.d.ts +1 -0
  55. package/dist/date-picker/YearGrid.svelte +6 -13
  56. package/dist/date-picker/YearGrid.svelte.d.ts +1 -0
  57. package/dist/date-picker/dateUtils.d.ts +4 -0
  58. package/dist/date-picker/dateUtils.js +15 -88
  59. package/dist/date-picker/exitVisibility.svelte.js +13 -6
  60. package/dist/date-picker/formatters.d.ts +16 -0
  61. package/dist/date-picker/formatters.js +48 -15
  62. package/dist/date-picker/internalTypes.d.ts +59 -1
  63. package/dist/date-picker/shortcuts.d.ts +11 -0
  64. package/dist/date-picker/shortcuts.js +40 -0
  65. package/dist/date-picker/timeUtils.d.ts +8 -3
  66. package/dist/date-picker/timeUtils.js +33 -51
  67. package/dist/date-picker/types.d.ts +22 -0
  68. package/dist/dialog/Dialog.svelte +171 -33
  69. package/dist/dialog/Dialog.svelte.d.ts +2 -1
  70. package/dist/dialog/types.d.ts +6 -0
  71. package/dist/divider/Divider.svelte +36 -10
  72. package/dist/divider/types.d.ts +1 -0
  73. package/dist/fab/ExtendedFab.svelte +26 -60
  74. package/dist/fab/Fab.svelte +13 -48
  75. package/dist/fab/FabMenu.svelte +102 -44
  76. package/dist/fab/FabMenu.svelte.d.ts +2 -1
  77. package/dist/fab/FabMenuItem.svelte +8 -0
  78. package/dist/fab/FabMenuItem.svelte.d.ts +4 -0
  79. package/dist/fab/index.d.ts +1 -0
  80. package/dist/fab/index.js +1 -0
  81. package/dist/fab/types.d.ts +6 -4
  82. package/dist/icons/Icon.svelte +1 -4
  83. package/dist/icons/MenuIcon.svelte +13 -0
  84. package/dist/icons/MenuIcon.svelte.d.ts +7 -0
  85. package/dist/icons/MenuOpenIcon.svelte +15 -0
  86. package/dist/icons/MenuOpenIcon.svelte.d.ts +7 -0
  87. package/dist/icons/index.d.ts +2 -0
  88. package/dist/icons/index.js +2 -0
  89. package/dist/internal/exit.css +33 -0
  90. package/dist/internal/field.css +337 -0
  91. package/dist/internal/fieldTypes.d.ts +6 -0
  92. package/dist/internal/focus-ring.css +25 -6
  93. package/dist/internal/interest.css +3 -1
  94. package/dist/internal/pop-in.css +19 -0
  95. package/dist/internal/visually-hidden.css +11 -0
  96. package/dist/keyboard-nav.d.ts +12 -1
  97. package/dist/keyboard-nav.js +104 -14
  98. package/dist/list/Item.svelte +84 -24
  99. package/dist/list/List.svelte +35 -2
  100. package/dist/list/ListItem.svelte +16 -2
  101. package/dist/list/context.d.ts +6 -0
  102. package/dist/list/context.js +4 -0
  103. package/dist/list/types.d.ts +7 -0
  104. package/dist/media.d.ts +1 -0
  105. package/dist/media.js +1 -0
  106. package/dist/menu/Menu.svelte +53 -20
  107. package/dist/menu/Menu.svelte.d.ts +3 -1
  108. package/dist/menu/MenuItem.svelte +9 -3
  109. package/dist/menu/types.d.ts +7 -1
  110. package/dist/navigation-bar/NavigationBar.svelte +9 -3
  111. package/dist/navigation-bar/NavigationBarItem.svelte +31 -39
  112. package/dist/navigation-drawer/NavigationDrawer.svelte +96 -20
  113. package/dist/navigation-drawer/NavigationDrawer.svelte.d.ts +2 -1
  114. package/dist/navigation-drawer/NavigationDrawerItem.svelte +10 -12
  115. package/dist/navigation-drawer/NavigationDrawerSection.svelte +49 -0
  116. package/dist/navigation-drawer/NavigationDrawerSection.svelte.d.ts +4 -0
  117. package/dist/navigation-drawer/index.d.ts +1 -0
  118. package/dist/navigation-drawer/index.js +1 -0
  119. package/dist/navigation-drawer/types.d.ts +12 -0
  120. package/dist/navigation-rail/NavigationRail.svelte +367 -7
  121. package/dist/navigation-rail/NavigationRail.svelte.d.ts +1 -1
  122. package/dist/navigation-rail/NavigationRailItem.svelte +185 -59
  123. package/dist/navigation-rail/NavigationRailSection.svelte +57 -0
  124. package/dist/navigation-rail/NavigationRailSection.svelte.d.ts +4 -0
  125. package/dist/navigation-rail/context.d.ts +6 -0
  126. package/dist/navigation-rail/context.js +5 -0
  127. package/dist/navigation-rail/index.d.ts +1 -0
  128. package/dist/navigation-rail/index.js +1 -0
  129. package/dist/navigation-rail/morph.d.ts +14 -0
  130. package/dist/navigation-rail/morph.js +78 -0
  131. package/dist/navigation-rail/types.d.ts +21 -0
  132. package/dist/press.svelte.d.ts +0 -1
  133. package/dist/press.svelte.js +0 -11
  134. package/dist/progress/CircularProgress.svelte +28 -82
  135. package/dist/progress/CircularProgress.svelte.d.ts +1 -0
  136. package/dist/progress/LinearProgress.svelte +67 -171
  137. package/dist/progress/LinearProgress.svelte.d.ts +1 -0
  138. package/dist/progress/four-color.css +34 -0
  139. package/dist/progress/types.d.ts +1 -0
  140. package/dist/radio/Radio.svelte +13 -10
  141. package/dist/ripple/Ripple.svelte +8 -16
  142. package/dist/search/Search.svelte +238 -58
  143. package/dist/search/Search.svelte.d.ts +1 -0
  144. package/dist/search/types.d.ts +4 -0
  145. package/dist/select/Check.svelte +8 -31
  146. package/dist/select/Check.svelte.d.ts +1 -1
  147. package/dist/select/NativeSelect.svelte +113 -374
  148. package/dist/select/NativeSelect.svelte.d.ts +1 -0
  149. package/dist/select/Option.svelte +2 -11
  150. package/dist/select/Select.svelte +170 -858
  151. package/dist/select/Select.svelte.d.ts +1 -0
  152. package/dist/select/VirtualList.svelte +16 -2
  153. package/dist/select/VirtualList.svelte.d.ts +6 -2
  154. package/dist/sheet/Sheet.svelte +229 -22
  155. package/dist/sheet/Sheet.svelte.d.ts +3 -1
  156. package/dist/sheet/types.d.ts +10 -0
  157. package/dist/slider/Slider.svelte +14 -64
  158. package/dist/slider/Slider.svelte.d.ts +1 -0
  159. package/dist/snackbar/Snackbar.svelte +3 -0
  160. package/dist/snackbar/Snackbar.svelte.d.ts +2 -1
  161. package/dist/switch/Switch.svelte +53 -19
  162. package/dist/tabs/Tab.svelte +30 -18
  163. package/dist/tabs/Tabs.svelte +28 -4
  164. package/dist/tabs/types.d.ts +3 -0
  165. package/dist/text-field/TextField.svelte +124 -616
  166. package/dist/text-field/TextField.svelte.d.ts +1 -0
  167. package/dist/time-picker/ClockDial.svelte +8 -25
  168. package/dist/time-picker/ClockDial.svelte.d.ts +2 -0
  169. package/dist/time-picker/DockedTimePicker.svelte +102 -239
  170. package/dist/time-picker/DockedTimePicker.svelte.d.ts +3 -3
  171. package/dist/time-picker/PeriodSelector.svelte +8 -7
  172. package/dist/time-picker/PeriodSelector.svelte.d.ts +1 -0
  173. package/dist/time-picker/TimeInputs.svelte +13 -2
  174. package/dist/time-picker/TimePickerDialog.svelte +33 -26
  175. package/dist/time-picker/TimePickerDialog.svelte.d.ts +3 -3
  176. package/dist/time-picker/TimePickerPanel.svelte +26 -4
  177. package/dist/time-picker/TimeSelectors.svelte +4 -9
  178. package/dist/time-picker/TimeSelectors.svelte.d.ts +1 -0
  179. package/dist/time-picker/internalTypes.d.ts +2 -0
  180. package/dist/time-picker/timePickerState.svelte.js +3 -9
  181. package/dist/toolbar/Toolbar.svelte +21 -11
  182. package/dist/toolbar/types.d.ts +1 -0
  183. package/dist/tooltip/RichTooltip.svelte +2 -23
  184. package/dist/tooltip/RichTooltip.svelte.d.ts +2 -1
  185. package/dist/tooltip/Tooltip.svelte +5 -25
  186. package/dist/tooltip/Tooltip.svelte.d.ts +2 -1
  187. package/package.json +12 -12
@@ -1,11 +1,23 @@
1
1
  <script lang="ts">
2
+ import { formReset } from '../form-reset.js'
3
+ import { arrowKeyNav, rovingTabindex } from '../keyboard-nav.js'
2
4
  import { reducedMotion } from '../media.js'
3
- import { onMount } from 'svelte'
4
- import { expandedWidths, parseMotion, resolveItem } from './buttonGroup.js'
5
+ import { onMount, untrack } from 'svelte'
6
+ import { motionOf } from '../animation.js'
7
+ import { expandedWidths, resolveItem } from './buttonGroup.js'
8
+ import { setButtonGroupContext } from './groupContext.js'
5
9
  import type { ButtonGroupProps } from './types.js'
6
10
 
7
11
  let {
8
12
  variant = 'standard',
13
+ size,
14
+ shape,
15
+ selection,
16
+ value = $bindable(),
17
+ required = false,
18
+ name,
19
+ form,
20
+ arrowKeys = true,
9
21
  expandedRatio = 0.15,
10
22
  compressionLimit = 24,
11
23
  element = $bindable(),
@@ -13,6 +25,37 @@
13
25
  ...attributes
14
26
  }: ButtonGroupProps = $props()
15
27
 
28
+ const selectedValues = $derived(
29
+ value == null ? [] : Array.isArray(value) ? value.map(String) : [String(value)],
30
+ )
31
+ setButtonGroupContext({
32
+ get selection() {
33
+ return selection
34
+ },
35
+ get size() {
36
+ return size
37
+ },
38
+ get shape() {
39
+ return shape
40
+ },
41
+ isSelected: (item) => selectedValues.includes(item),
42
+ toggle: (item) => {
43
+ const on = selectedValues.includes(item)
44
+ if (on && required && selectedValues.length === 1) return
45
+ if (selection === 'single') value = on ? null : item
46
+ else value = on ? selectedValues.filter((v) => v !== item) : [...selectedValues, item]
47
+ },
48
+ })
49
+
50
+ // A form reset brings back the selection the group started with.
51
+ const initialValue = untrack(() => (Array.isArray(value) ? [...value] : value))
52
+ const restore = () => (value = Array.isArray(initialValue) ? [...initialValue] : initialValue)
53
+
54
+ // The buttons share one tab stop, Tab enters the group and the arrow keys move inside it.
55
+ const ITEMS = 'button, a[href]'
56
+ const roving = rovingTabindex(ITEMS)
57
+ const arrows = arrowKeyNav(ITEMS, 'horizontal')
58
+
16
59
  const settleRatio = 0.75
17
60
 
18
61
  type Timing = { duration: number; easing: string }
@@ -30,15 +73,15 @@
30
73
  const items = () =>
31
74
  element
32
75
  ? (Array.from(element.children) as HTMLElement[])
33
- .filter((item) => !item.hasAttribute('popover'))
76
+ .filter(
77
+ (item) => !item.hasAttribute('popover') && !item.matches('.np-button-group-input'),
78
+ )
34
79
  .map(resolveItem)
35
80
  : []
36
81
 
37
82
  const motion = () =>
38
83
  element
39
- ? parseMotion(
40
- getComputedStyle(element).getPropertyValue('--np-motion-expressive-fast-spatial'),
41
- )
84
+ ? motionOf(element, '--np-motion-expressive-fast-spatial')
42
85
  : { duration: 0, easing: 'linear' }
43
86
 
44
87
  const animate = (
@@ -130,6 +173,7 @@
130
173
  {...attributes}
131
174
  role={attributes.role ?? 'group'}
132
175
  bind:this={element}
176
+ {@attach (node) => (arrowKeys ? roving(node) : undefined)}
133
177
  class={['np-button-group', variant, moving && 'moving', attributes.class]}
134
178
  onpointerdown={(event) => {
135
179
  press(event.target)
@@ -152,6 +196,7 @@
152
196
  press(event.target)
153
197
  }
154
198
  attributes.onkeydown?.(event)
199
+ if (arrowKeys && !event.defaultPrevented) arrows(event)
155
200
  }}
156
201
  onkeyup={(event) => {
157
202
  release()
@@ -163,38 +208,109 @@
163
208
  }}
164
209
  >
165
210
  {@render children?.()}
211
+ {#if selection && name}
212
+ <!-- Takes part in the form like a radio or checkbox group: submit, reset and required. -->
213
+ <select
214
+ class="np-button-group-input"
215
+ {name}
216
+ {form}
217
+ {required}
218
+ multiple={selection === 'multiple'}
219
+ tabindex="-1"
220
+ aria-hidden="true"
221
+ {@attach formReset(restore)}
222
+ >
223
+ {#each selectedValues as selected (selected)}
224
+ <option value={selected} selected>{selected}</option>
225
+ {/each}
226
+ </select>
227
+ {/if}
166
228
  </div>
167
229
 
168
230
  <style>
231
+ /* A standard group hugs its buttons. The space between them grows as they shrink, so each
232
+ keeps a 48px target: 18px beside extra small buttons, 12px beside small ones, 8px above. */
169
233
  .np-button-group {
170
- display: flex;
234
+ position: relative;
235
+ display: inline-flex;
171
236
  align-items: flex-start;
172
- gap: var(--np-button-group-space, 0.75rem);
237
+ gap: var(--np-button-group-space, var(--_space, 0.5rem));
173
238
  isolation: isolate;
174
239
  }
240
+ .np-button-group:has(> :global(:is(.np-button, .np-icon-button).s)) {
241
+ --_space: 0.75rem;
242
+ }
243
+ .np-button-group:has(> :global(:is(.np-button, .np-icon-button).xs)) {
244
+ --_space: 1.125rem;
245
+ }
246
+ /* A connected group spans its container and widens its buttons to fill it. */
175
247
  .connected {
248
+ display: flex;
176
249
  gap: var(--np-button-group-space, 0.125rem);
177
250
  }
251
+ .connected > :global(:not([popover], .np-button-group-input)) {
252
+ flex: 1 1 auto;
253
+ }
254
+ .connected > :global(.np-icon-button:is(.xs, .s)) {
255
+ min-width: 3rem;
256
+ }
257
+ /* The corners where two buttons meet, and every corner of a square group. */
258
+ .connected > :global(.xs) {
259
+ --_inner-corner: 0.25rem;
260
+ }
261
+ .connected > :global(:is(.s, .m)) {
262
+ --_inner-corner: 0.5rem;
263
+ }
264
+ .connected > :global(.l) {
265
+ --_inner-corner: 1rem;
266
+ }
267
+ .connected > :global(.xl) {
268
+ --_inner-corner: 1.25rem;
269
+ }
270
+ .connected > :global(.square:not(.selected)) {
271
+ border-radius: var(--np-button-group-inner-corner, var(--_inner-corner));
272
+ }
273
+
274
+ .np-button-group-input {
275
+ position: absolute;
276
+ inset-inline-start: 0;
277
+ bottom: 0;
278
+ width: 1px;
279
+ height: 1px;
280
+ opacity: 0;
281
+ pointer-events: none;
282
+ }
178
283
 
179
284
  .np-button-group > :global(:focus-visible) {
180
285
  z-index: 1;
181
286
  }
287
+ /* While widths animate, a button may be narrower than its label for a moment. */
182
288
  .moving {
183
- --_button-label-overflow: visible;
184
- --_button-label-text-overflow: clip;
289
+ --_button-min-width: 0;
290
+ --_button-label-overflow: hidden;
185
291
  }
186
292
 
187
- .connected > :global(:not([popover]):has(~ :not([popover]))) {
188
- border-start-end-radius: var(--np-button-group-inner-corner, var(--np-shape-corner-small));
189
- border-end-end-radius: var(--np-button-group-inner-corner, var(--np-shape-corner-small));
293
+ .connected
294
+ > :global(
295
+ :not([popover], .np-button-group-input):has(~ :not([popover], .np-button-group-input))
296
+ ) {
297
+ border-start-end-radius: var(--np-button-group-inner-corner, var(--_inner-corner));
298
+ border-end-end-radius: var(--np-button-group-inner-corner, var(--_inner-corner));
190
299
  }
191
- .connected > :global(:not([popover]) ~ :not([popover])) {
192
- border-start-start-radius: var(--np-button-group-inner-corner, var(--np-shape-corner-small));
193
- border-end-start-radius: var(--np-button-group-inner-corner, var(--np-shape-corner-small));
300
+ .connected
301
+ > :global(:not([popover], .np-button-group-input) ~ :not([popover], .np-button-group-input)) {
302
+ border-start-start-radius: var(--np-button-group-inner-corner, var(--_inner-corner));
303
+ border-end-start-radius: var(--np-button-group-inner-corner, var(--_inner-corner));
194
304
  }
195
305
 
196
306
  @media (prefers-reduced-motion: no-preference) {
197
- .connected > :global(:not([popover]):has(~ :not([popover])):is(:active, .pressed)) {
307
+ .connected
308
+ > :global(
309
+ :not([popover], .np-button-group-input):has(~ :not([popover], .np-button-group-input)):is(
310
+ :active,
311
+ .pressed
312
+ )
313
+ ) {
198
314
  border-start-end-radius: var(
199
315
  --np-button-group-pressed-inner-corner,
200
316
  var(--np-shape-corner-extra-small)
@@ -204,7 +320,11 @@
204
320
  var(--np-shape-corner-extra-small)
205
321
  );
206
322
  }
207
- .connected > :global(:not([popover]) ~ :not([popover]):is(:active, .pressed)) {
323
+ .connected
324
+ > :global(
325
+ :not([popover], .np-button-group-input)
326
+ ~ :not([popover], .np-button-group-input):is(:active, .pressed)
327
+ ) {
208
328
  border-start-start-radius: var(
209
329
  --np-button-group-pressed-inner-corner,
210
330
  var(--np-shape-corner-extra-small)
@@ -216,7 +336,7 @@
216
336
  }
217
337
  }
218
338
 
219
- .connected > :global(:not([popover]).selected) {
339
+ .connected > :global(:not([popover], .np-button-group-input).selected) {
220
340
  border-radius: var(--_round-radius, var(--np-shape-corner-full));
221
341
  }
222
342
  </style>
@@ -1,4 +1,4 @@
1
1
  import type { ButtonGroupProps } from './types.js';
2
- declare const ButtonGroup: import("svelte").Component<ButtonGroupProps, {}, "element">;
2
+ declare const ButtonGroup: import("svelte").Component<ButtonGroupProps, {}, "element" | "value">;
3
3
  type ButtonGroup = ReturnType<typeof ButtonGroup>;
4
4
  export default ButtonGroup;
@@ -5,6 +5,7 @@
5
5
  import Ripple from '../ripple/Ripple.svelte'
6
6
  import Tooltip from '../tooltip/Tooltip.svelte'
7
7
  import type { HTMLButtonAttributes } from 'svelte/elements'
8
+ import { getButtonGroupContext } from './groupContext.js'
8
9
  import type { IconButtonProps } from './types.js'
9
10
 
10
11
  let {
@@ -19,8 +20,8 @@
19
20
  selected = $bindable(false),
20
21
  selectedIcon,
21
22
  width = 'default',
22
- size = 's',
23
- shape = 'round',
23
+ size: sizeProp,
24
+ shape: shapeProp,
24
25
  ...attributes
25
26
  }: IconButtonProps = $props()
26
27
 
@@ -28,6 +29,25 @@
28
29
 
29
30
  let isLink = $derived(attributes.href != null && !disabled && !loading)
30
31
  let tooltipId = $derived(title && !disabled && !loading ? uid : undefined)
32
+ // Without an aria-label, the tooltip text is the name, and describing the button with it again
33
+ // would read it twice.
34
+ let tooltipNames = $derived(!!tooltipId && !attributes['aria-label'])
35
+ let name = $derived(attributes['aria-label'] ?? (title || undefined))
36
+
37
+ // Inside a ButtonGroup, the group sets the size and shape its buttons leave open, and with
38
+ // `selection` it decides what is selected.
39
+ const group = getButtonGroupContext()
40
+ let size = $derived(sizeProp ?? group?.size ?? 's')
41
+ let shape = $derived(shapeProp ?? group?.shape ?? 'round')
42
+ let groupValue = $derived(
43
+ group?.selection && attributes.value != null ? String(attributes.value) : undefined,
44
+ )
45
+ let isToggle = $derived(toggle || groupValue !== undefined)
46
+ let isSelected = $derived(groupValue !== undefined ? group!.isSelected(groupValue) : selected)
47
+ // A selected toggle swaps its resting shape: round turns square, square turns round.
48
+ let shapeClass = $derived(
49
+ isSelected ? (shape === 'square' ? 'round' : 'square') : loading ? 'square' : shape,
50
+ )
31
51
 
32
52
  const morph = pressMorph()
33
53
 
@@ -47,7 +67,7 @@
47
67
  <CircularProgress aria-label={loadingAriaLabel} indeterminate track={false} />
48
68
  </div>
49
69
  {/if}
50
- {#if selectedIcon && selected}
70
+ {#if selectedIcon && isSelected}
51
71
  {@render selectedIcon()}
52
72
  {:else if children}
53
73
  {@render children()}
@@ -58,24 +78,25 @@
58
78
  <a
59
79
  {...attributes}
60
80
  onclick={(event) => {
61
- if (!toggle) {
81
+ if (!isToggle) {
62
82
  handlePress()
63
83
  }
64
84
  attributes.onclick?.(event)
65
85
  }}
66
- aria-describedby={tooltipId ?? attributes['aria-describedby']}
86
+ aria-describedby={(!tooltipNames && tooltipId) || attributes['aria-describedby']}
67
87
  interestfor={tooltipId ?? attributes['interestfor']}
68
- aria-label={title || attributes['aria-label']}
88
+ aria-label={name}
69
89
  bind:this={element}
70
90
  class={[
71
91
  'np-icon-button',
92
+ 'np-focus-ring',
72
93
  size,
73
94
  width,
74
95
  variant,
75
- selected ? 'square' : shape,
96
+ shapeClass,
76
97
  'enabled',
77
- toggle && 'toggle',
78
- selected && 'selected',
98
+ isToggle && 'toggle',
99
+ isSelected && 'selected',
79
100
  morph.pressed && 'pressed',
80
101
  attributes.class,
81
102
  ].filter(Boolean)}
@@ -85,16 +106,18 @@
85
106
  {:else}
86
107
  <button
87
108
  {...attributes as HTMLButtonAttributes}
88
- aria-describedby={tooltipId ?? attributes['aria-describedby']}
109
+ aria-describedby={(!tooltipNames && tooltipId) || attributes['aria-describedby']}
89
110
  interestfor={tooltipId ?? attributes['interestfor']}
90
- aria-label={title || attributes['aria-label']}
91
- aria-pressed={toggle ? selected : undefined}
111
+ aria-label={name}
112
+ aria-pressed={isToggle ? isSelected : undefined}
92
113
  aria-busy={loading}
93
114
  type={(attributes['type'] as 'button' | 'submit' | 'reset' | 'button') ?? undefined}
94
115
  disabled={disabled || loading}
95
116
  bind:this={element}
96
117
  onclick={(event) => {
97
- if (toggle) {
118
+ if (groupValue !== undefined) {
119
+ group!.toggle(groupValue)
120
+ } else if (toggle) {
98
121
  selected = !selected
99
122
  } else {
100
123
  handlePress()
@@ -103,12 +126,13 @@
103
126
  }}
104
127
  class={[
105
128
  'np-icon-button',
129
+ 'np-focus-ring',
106
130
  size,
107
131
  width,
108
- selected || loading ? 'square' : shape,
132
+ shapeClass,
109
133
  disabled || loading ? `${variant}-disabled disabled` : `${variant} enabled`,
110
- toggle && 'toggle',
111
- selected && 'selected',
134
+ isToggle && 'toggle',
135
+ isSelected && 'selected',
112
136
  morph.pressed && 'pressed',
113
137
  attributes.class,
114
138
  ]}
@@ -272,17 +296,6 @@
272
296
  outline-width: 1px;
273
297
  outline-offset: -1px;
274
298
  }
275
- .enabled:focus-visible {
276
- outline-style: solid;
277
- outline-color: var(--np-color-secondary);
278
- outline-width: 3px;
279
- outline-offset: 2px;
280
- }
281
- @media (prefers-reduced-motion: no-preference) {
282
- .enabled:focus-visible {
283
- animation: focusAnimation var(--np-motion-expressive-slow-effects) forwards;
284
- }
285
- }
286
299
  .text {
287
300
  color: var(--np-icon-button-icon-color, var(--np-color-on-surface-variant));
288
301
  }
@@ -316,8 +329,14 @@
316
329
  );
317
330
  }
318
331
  .tonal.toggle {
319
- color: var(--np-color-on-secondary-container);
320
- background-color: var(--np-color-secondary-container);
332
+ color: var(
333
+ --np-tonal-icon-button-unselected-icon-color,
334
+ var(--np-color-on-secondary-container)
335
+ );
336
+ background-color: var(
337
+ --np-tonal-icon-button-unselected-container-color,
338
+ var(--np-color-secondary-container)
339
+ );
321
340
  }
322
341
  .tonal.selected {
323
342
  color: var(--np-color-on-secondary);
@@ -332,8 +351,7 @@
332
351
  color: var(--np-color-on-surface-variant);
333
352
  }
334
353
 
335
- .outlined:not(.selected)::after,
336
- .outlined-disabled::after {
354
+ .outlined:not(.selected)::after {
337
355
  content: '';
338
356
  position: absolute;
339
357
  inset: 0;
@@ -348,8 +366,8 @@
348
366
  }
349
367
  .np-touch {
350
368
  position: absolute;
351
- height: max(calc(var(--_button-height, 40px) + 8px), 100%);
352
- width: max(calc(var(--_button-width, 40px) + 8px), 100%);
369
+ height: max(3rem, 100%);
370
+ width: max(3rem, 100%);
353
371
  }
354
372
 
355
373
  .np-icon-button-circular-progress {
@@ -1,6 +1,7 @@
1
1
  <script lang="ts">
2
2
  import '../internal/focus-ring.css'
3
3
  import CheckIcon from '../icons/CheckIcon.svelte'
4
+ import { arrowKeyNav, rovingTabindex } from '../keyboard-nav.js'
4
5
  import Ripple from '../ripple/Ripple.svelte'
5
6
  import type { SegmentedButtonProps } from './types.js'
6
7
 
@@ -15,18 +16,43 @@
15
16
  }: SegmentedButtonProps = $props()
16
17
 
17
18
  let hasError = $derived(!!issues?.length)
19
+
20
+ // Radios already share one tab stop and move with the arrow keys. Checkboxes get the same.
21
+ const SEGMENTS = 'input'
22
+ const roving = rovingTabindex(SEGMENTS)
23
+ const arrows = arrowKeyNav(SEGMENTS, 'horizontal')
18
24
  </script>
19
25
 
20
26
  <div
27
+ role={multiSelect ? 'group' : 'radiogroup'}
21
28
  {...attributes}
22
29
  class={['np-segmented-buttons', hasError && 'np-error', attributes.class]}
23
30
  bind:this={element}
31
+ {@attach (node) => (multiSelect ? roving(node) : undefined)}
32
+ onkeydown={(event) => {
33
+ attributes.onkeydown?.(event)
34
+ if (event.defaultPrevented) return
35
+ // Enter picks the focused segment, like Space, instead of submitting the form.
36
+ if (event.key === 'Enter' && event.target instanceof HTMLInputElement) {
37
+ event.preventDefault()
38
+ if (!(event.target.type === 'radio' && event.target.checked)) event.target.click()
39
+ return
40
+ }
41
+ if (multiSelect) arrows(event)
42
+ }}
24
43
  style="{attributes.style};grid-template-columns: repeat({options.length}, minmax(max-content, 1fr));"
25
44
  >
26
45
  {#each options as option, i (i)}
27
46
  {let optionLabel = $derived(option.label ?? `${name}-${i}`)}
28
47
  {let optionValue = $derived(option.value ?? optionLabel)}
29
- <label class={['np-segmented-button', option.icon && 'width-icon']}>
48
+ <label
49
+ class={[
50
+ 'np-segmented-button',
51
+ 'np-focus-ring-within',
52
+ 'np-focus-ring-inset',
53
+ option.icon && 'width-icon',
54
+ ]}
55
+ >
30
56
  <div class="check-icon-wrapper">
31
57
  <div class="check-icon">
32
58
  <CheckIcon />
@@ -48,6 +74,7 @@
48
74
  {/if}
49
75
  {#if !option.disabled}
50
76
  <Ripple />
77
+ <span class="np-touch"></span>
51
78
  {/if}
52
79
  {#if group !== undefined}
53
80
  {#if multiSelect}
@@ -93,11 +120,13 @@
93
120
  /* State layers take the content color of each variant and state. */
94
121
  --np-ripple-hover-color: currentColor;
95
122
  --np-ripple-pressed-color: currentColor;
96
- display: grid;
123
+ /* As wide as its labels need, every segment as wide as the widest. */
124
+ display: inline-grid;
125
+ box-sizing: border-box;
126
+ min-height: 2.5rem;
97
127
  color: var(--np-color-on-surface);
98
128
  border: 1px solid var(--np-color-outline);
99
129
  border-radius: var(--np-shape-corner-full);
100
- overflow-x: auto;
101
130
  }
102
131
 
103
132
  .np-segmented-buttons.np-error {
@@ -111,7 +140,7 @@
111
140
 
112
141
  .np-segmented-button {
113
142
  flex: 1;
114
- padding: 0.5rem 1.75rem;
143
+ padding: 0 1.75rem;
115
144
  text-align: center;
116
145
  cursor: pointer;
117
146
  font-size: 0.875rem;
@@ -157,12 +186,30 @@
157
186
  inset: 0;
158
187
  z-index: -1;
159
188
  opacity: 0;
189
+ border-radius: inherit;
160
190
  transition: opacity var(--np-motion-expressive-fast-effects);
161
191
  background-color: var(--np-color-secondary-container);
162
192
  }
163
193
  .width-icon,
164
194
  .np-segmented-button:has(input:checked) {
165
- padding: 0.5rem 0.75rem;
195
+ padding: 0 0.75rem;
196
+ }
197
+ /* The outer segments round off the container's ends, for their fill and state layer too. */
198
+ .np-segmented-button:first-child {
199
+ border-start-start-radius: var(--np-shape-corner-full);
200
+ border-end-start-radius: var(--np-shape-corner-full);
201
+ }
202
+ .np-segmented-button:last-child {
203
+ border-start-end-radius: var(--np-shape-corner-full);
204
+ border-end-end-radius: var(--np-shape-corner-full);
205
+ }
206
+ /* Each segment keeps a 48px tall target. */
207
+ .np-touch {
208
+ position: absolute;
209
+ inset-inline: 0;
210
+ top: 50%;
211
+ translate: 0 -50%;
212
+ height: max(3rem, 100%);
166
213
  }
167
214
  .np-segmented-button:has(input:checked) {
168
215
  color: var(--np-color-on-secondary-container);
@@ -217,13 +264,6 @@
217
264
  opacity: var(--np-ripple-focus-opacity, 0.1);
218
265
  }
219
266
 
220
- .np-segmented-button:has(input:focus-visible) {
221
- outline-style: solid;
222
- outline-color: var(--np-color-secondary);
223
- outline-width: 3px;
224
- outline-offset: -3px;
225
- }
226
-
227
267
  @media (prefers-reduced-motion: no-preference) {
228
268
  .np-segmented-button {
229
269
  transition: all var(--np-motion-expressive-fast-effects);
@@ -242,8 +282,5 @@
242
282
  width var(--np-motion-expressive-fast-effects),
243
283
  opacity var(--np-motion-expressive-fast-effects);
244
284
  }
245
- .np-segmented-button:has(input:focus-visible) {
246
- animation: focusAnimationInset var(--np-motion-expressive-slow-effects) forwards;
247
- }
248
285
  }
249
286
  </style>
@@ -7,6 +7,7 @@
7
7
 
8
8
  let {
9
9
  label = '',
10
+ iconOnly = false,
10
11
  icon,
11
12
  menu,
12
13
  variant = 'filled',
@@ -29,13 +30,19 @@
29
30
  <ChevronDownIcon />
30
31
  {/snippet}
31
32
 
33
+ <!-- Tab moves from the leading to the trailing button, the arrow keys are not needed. -->
32
34
  <ButtonGroup
33
35
  {...attributes}
34
36
  bind:element
35
37
  variant="connected"
38
+ arrowKeys={false}
36
39
  class={['np-split-button', attributes.class]}
37
40
  >
38
- <Button {variant} {size} {disabled} {onclick} start={icon}>{label}</Button>
41
+ {#if iconOnly}
42
+ <Button {variant} {size} {disabled} {onclick} start={icon} title={label} />
43
+ {:else}
44
+ <Button {variant} {size} {disabled} {onclick} start={icon}>{label}</Button>
45
+ {/if}
39
46
  <Button
40
47
  {variant}
41
48
  {size}
@@ -55,12 +62,79 @@
55
62
  </ButtonGroup>
56
63
 
57
64
  <style>
58
- :global(.np-split-button-trigger svg) {
59
- transition: rotate var(--np-motion-expressive-fast-effects);
65
+ /* A split button keeps its own width instead of spanning its container like other connected
66
+ groups. */
67
+ :global(.np-button-group.np-split-button) {
68
+ display: inline-flex;
69
+ }
70
+ :global(.np-split-button > .np-button) {
71
+ flex: none;
72
+ }
73
+
74
+ /* The inner corners per size, rounder while hovered, focused or pressed. */
75
+ :global(.np-split-button > .np-button) {
76
+ --np-button-group-inner-corner: var(--_split-inner, 0.25rem);
77
+ --np-button-group-pressed-inner-corner: var(--_split-inner-active, 0.75rem);
78
+ }
79
+ :global(.np-split-button > .np-button:is(:focus-visible, :active, .pressed)) {
80
+ --_split-inner: var(--_split-inner-active, 0.75rem);
81
+ }
82
+ @media (hover: hover) {
83
+ :global(.np-split-button > .np-button:hover) {
84
+ --_split-inner: var(--_split-inner-active, 0.75rem);
85
+ }
86
+ }
87
+ /* Pressed, only the inner corners change, the outer ends stay round. */
88
+ @media (prefers-reduced-motion: no-preference) {
89
+ :global(.np-split-button > .np-button:not(.np-split-button-trigger):is(:active, .pressed)) {
90
+ border-start-start-radius: var(--_round-radius);
91
+ border-end-start-radius: var(--_round-radius);
92
+ }
93
+ :global(.np-split-button > .np-split-button-trigger:is(:active, .pressed)) {
94
+ border-start-end-radius: var(--_round-radius);
95
+ border-end-end-radius: var(--_round-radius);
96
+ }
97
+ }
98
+ :global(.np-split-button > .xs) {
99
+ --_split-inner-active: 0.5rem;
100
+ }
101
+ :global(.np-split-button > .l) {
102
+ --_split-inner: 0.5rem;
103
+ --_split-inner-active: 1.25rem;
104
+ }
105
+ :global(.np-split-button > .xl) {
106
+ --_split-inner: 0.75rem;
107
+ --_split-inner-active: 1.25rem;
108
+ }
109
+
110
+ /* Closed, the caret sits a little toward the leading button; open, it is centered. */
111
+ :global(.np-split-button-trigger .button-icon) {
112
+ position: relative;
113
+ inset-inline-start: var(--_caret-offset, -1px);
114
+ }
115
+ :global(.np-split-button-trigger.m .button-icon) {
116
+ --_caret-offset: -2px;
117
+ }
118
+ :global(.np-split-button-trigger.l .button-icon) {
119
+ --_caret-offset: -3px;
120
+ }
121
+ :global(.np-split-button-trigger.xl .button-icon) {
122
+ --_caret-offset: -6px;
123
+ }
124
+ :global(.np-split-button-trigger[aria-expanded='true'] .button-icon) {
125
+ --_caret-offset: 0px;
60
126
  }
61
127
  :global(.np-split-button-trigger[aria-expanded='true'] svg) {
62
128
  rotate: 180deg;
63
129
  }
130
+ @media (prefers-reduced-motion: no-preference) {
131
+ :global(.np-split-button-trigger svg) {
132
+ transition: rotate var(--np-motion-standard-fast-spatial);
133
+ }
134
+ :global(.np-split-button-trigger .button-icon) {
135
+ transition: inset-inline-start var(--np-motion-standard-fast-spatial);
136
+ }
137
+ }
64
138
  :global(.np-split-button > .np-split-button-trigger) {
65
139
  --np-button-padding: 0;
66
140
  aspect-ratio: 1;
@@ -1,4 +1,4 @@
1
1
  import type { SplitButtonProps } from './types.js';
2
- declare const SplitButton: import("svelte").Component<SplitButtonProps, {}, "element" | "open">;
2
+ declare const SplitButton: import("svelte").Component<SplitButtonProps, {}, "open" | "element">;
3
3
  type SplitButton = ReturnType<typeof SplitButton>;
4
4
  export default SplitButton;
@@ -1,6 +1,2 @@
1
1
  export declare const resolveItem: (item: HTMLElement) => HTMLElement;
2
2
  export declare const expandedWidths: (widths: number[], index: number, expandedRatio: number, compressionLimit: number) => number[];
3
- export declare const parseMotion: (token: string) => {
4
- duration: number;
5
- easing: string;
6
- };