noph-ui 0.51.2 → 0.52.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 (130) hide show
  1. package/dist/animation.d.ts +26 -1
  2. package/dist/animation.js +50 -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 +55 -20
  11. package/dist/button/ButtonGroup.svelte +139 -19
  12. package/dist/button/ButtonGroup.svelte.d.ts +1 -1
  13. package/dist/button/IconButton.svelte +49 -18
  14. package/dist/button/SegmentedButton.svelte +44 -4
  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/checkbox/Checkbox.svelte +5 -0
  23. package/dist/chip/ChipSet.svelte +28 -2
  24. package/dist/chip/FilterChip.svelte +32 -4
  25. package/dist/chip/InputChip.svelte +127 -26
  26. package/dist/chip/SuggestionChip.svelte +6 -22
  27. package/dist/chip/SuggestionChip.svelte.d.ts +1 -2
  28. package/dist/chip/types.d.ts +8 -1
  29. package/dist/date-picker/Calendar.svelte +4 -2
  30. package/dist/date-picker/DatePickerDialog.svelte +1 -0
  31. package/dist/date-picker/DatePickerDialog.svelte.d.ts +1 -1
  32. package/dist/date-picker/DateRangePicker.svelte +1 -0
  33. package/dist/date-picker/DateRangePicker.svelte.d.ts +1 -1
  34. package/dist/date-picker/DockedDatePicker.svelte +12 -5
  35. package/dist/date-picker/DockedDatePicker.svelte.d.ts +1 -1
  36. package/dist/date-picker/DockedDateTimePicker.svelte +12 -5
  37. package/dist/date-picker/DockedDateTimePicker.svelte.d.ts +1 -1
  38. package/dist/date-picker/YearGrid.svelte +4 -2
  39. package/dist/dialog/Dialog.svelte +171 -33
  40. package/dist/dialog/Dialog.svelte.d.ts +2 -1
  41. package/dist/dialog/types.d.ts +6 -0
  42. package/dist/divider/Divider.svelte +36 -10
  43. package/dist/divider/types.d.ts +1 -0
  44. package/dist/fab/ExtendedFab.svelte +26 -60
  45. package/dist/fab/Fab.svelte +13 -48
  46. package/dist/fab/FabMenu.svelte +102 -44
  47. package/dist/fab/FabMenu.svelte.d.ts +2 -1
  48. package/dist/fab/FabMenuItem.svelte +8 -0
  49. package/dist/fab/FabMenuItem.svelte.d.ts +4 -0
  50. package/dist/fab/index.d.ts +1 -0
  51. package/dist/fab/index.js +1 -0
  52. package/dist/fab/types.d.ts +6 -4
  53. package/dist/icons/MenuIcon.svelte +13 -0
  54. package/dist/icons/MenuIcon.svelte.d.ts +7 -0
  55. package/dist/icons/MenuOpenIcon.svelte +15 -0
  56. package/dist/icons/MenuOpenIcon.svelte.d.ts +7 -0
  57. package/dist/icons/index.d.ts +2 -0
  58. package/dist/icons/index.js +2 -0
  59. package/dist/internal/exit.css +47 -0
  60. package/dist/internal/fieldTypes.d.ts +6 -0
  61. package/dist/internal/focus-ring.css +4 -0
  62. package/dist/internal/interest.css +3 -1
  63. package/dist/keyboard-nav.d.ts +12 -1
  64. package/dist/keyboard-nav.js +104 -14
  65. package/dist/list/Item.svelte +52 -6
  66. package/dist/list/List.svelte +35 -2
  67. package/dist/list/ListItem.svelte +16 -2
  68. package/dist/list/context.d.ts +6 -0
  69. package/dist/list/context.js +4 -0
  70. package/dist/list/types.d.ts +7 -0
  71. package/dist/media.d.ts +1 -0
  72. package/dist/media.js +1 -0
  73. package/dist/menu/Menu.svelte +70 -18
  74. package/dist/menu/Menu.svelte.d.ts +2 -1
  75. package/dist/menu/MenuItem.svelte +9 -3
  76. package/dist/menu/types.d.ts +7 -1
  77. package/dist/navigation-bar/NavigationBar.svelte +9 -3
  78. package/dist/navigation-bar/NavigationBarItem.svelte +29 -13
  79. package/dist/navigation-drawer/NavigationDrawer.svelte +96 -20
  80. package/dist/navigation-drawer/NavigationDrawer.svelte.d.ts +2 -1
  81. package/dist/navigation-drawer/NavigationDrawerItem.svelte +8 -3
  82. package/dist/navigation-drawer/NavigationDrawerSection.svelte +49 -0
  83. package/dist/navigation-drawer/NavigationDrawerSection.svelte.d.ts +4 -0
  84. package/dist/navigation-drawer/index.d.ts +1 -0
  85. package/dist/navigation-drawer/index.js +1 -0
  86. package/dist/navigation-drawer/types.d.ts +12 -0
  87. package/dist/navigation-rail/NavigationRail.svelte +367 -7
  88. package/dist/navigation-rail/NavigationRail.svelte.d.ts +1 -1
  89. package/dist/navigation-rail/NavigationRailItem.svelte +190 -53
  90. package/dist/navigation-rail/NavigationRailSection.svelte +57 -0
  91. package/dist/navigation-rail/NavigationRailSection.svelte.d.ts +4 -0
  92. package/dist/navigation-rail/context.d.ts +6 -0
  93. package/dist/navigation-rail/context.js +5 -0
  94. package/dist/navigation-rail/index.d.ts +1 -0
  95. package/dist/navigation-rail/index.js +1 -0
  96. package/dist/navigation-rail/morph.d.ts +14 -0
  97. package/dist/navigation-rail/morph.js +78 -0
  98. package/dist/navigation-rail/types.d.ts +21 -0
  99. package/dist/progress/CircularProgress.svelte +2 -1
  100. package/dist/progress/LinearProgress.svelte +48 -20
  101. package/dist/progress/types.d.ts +1 -0
  102. package/dist/radio/Radio.svelte +7 -1
  103. package/dist/search/Search.svelte +245 -58
  104. package/dist/search/types.d.ts +4 -0
  105. package/dist/select/NativeSelect.svelte +20 -12
  106. package/dist/select/Option.svelte +1 -2
  107. package/dist/select/Select.svelte +59 -140
  108. package/dist/select/VirtualList.svelte +16 -1
  109. package/dist/select/VirtualList.svelte.d.ts +6 -2
  110. package/dist/sheet/Sheet.svelte +223 -5
  111. package/dist/sheet/Sheet.svelte.d.ts +3 -1
  112. package/dist/sheet/types.d.ts +10 -0
  113. package/dist/slider/Slider.svelte +1 -27
  114. package/dist/snackbar/Snackbar.svelte +3 -0
  115. package/dist/snackbar/Snackbar.svelte.d.ts +2 -1
  116. package/dist/switch/Switch.svelte +51 -11
  117. package/dist/tabs/Tab.svelte +28 -14
  118. package/dist/tabs/Tabs.svelte +28 -4
  119. package/dist/tabs/types.d.ts +3 -0
  120. package/dist/text-field/TextField.svelte +49 -13
  121. package/dist/time-picker/DockedTimePicker.svelte +6 -1
  122. package/dist/time-picker/DockedTimePicker.svelte.d.ts +1 -1
  123. package/dist/time-picker/TimePickerDialog.svelte +1 -0
  124. package/dist/time-picker/TimePickerDialog.svelte.d.ts +1 -1
  125. package/dist/toolbar/Toolbar.svelte +21 -11
  126. package/dist/toolbar/types.d.ts +1 -0
  127. package/dist/tooltip/RichTooltip.svelte.d.ts +1 -1
  128. package/dist/tooltip/Tooltip.svelte +3 -1
  129. package/dist/tooltip/Tooltip.svelte.d.ts +1 -1
  130. package/package.json +1 -1
@@ -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,24 @@
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',
72
92
  size,
73
93
  width,
74
94
  variant,
75
- selected ? 'square' : shape,
95
+ shapeClass,
76
96
  'enabled',
77
- toggle && 'toggle',
78
- selected && 'selected',
97
+ isToggle && 'toggle',
98
+ isSelected && 'selected',
79
99
  morph.pressed && 'pressed',
80
100
  attributes.class,
81
101
  ].filter(Boolean)}
@@ -85,16 +105,18 @@
85
105
  {:else}
86
106
  <button
87
107
  {...attributes as HTMLButtonAttributes}
88
- aria-describedby={tooltipId ?? attributes['aria-describedby']}
108
+ aria-describedby={(!tooltipNames && tooltipId) || attributes['aria-describedby']}
89
109
  interestfor={tooltipId ?? attributes['interestfor']}
90
- aria-label={title || attributes['aria-label']}
91
- aria-pressed={toggle ? selected : undefined}
110
+ aria-label={name}
111
+ aria-pressed={isToggle ? isSelected : undefined}
92
112
  aria-busy={loading}
93
113
  type={(attributes['type'] as 'button' | 'submit' | 'reset' | 'button') ?? undefined}
94
114
  disabled={disabled || loading}
95
115
  bind:this={element}
96
116
  onclick={(event) => {
97
- if (toggle) {
117
+ if (groupValue !== undefined) {
118
+ group!.toggle(groupValue)
119
+ } else if (toggle) {
98
120
  selected = !selected
99
121
  } else {
100
122
  handlePress()
@@ -105,10 +127,10 @@
105
127
  'np-icon-button',
106
128
  size,
107
129
  width,
108
- selected || loading ? 'square' : shape,
130
+ shapeClass,
109
131
  disabled || loading ? `${variant}-disabled disabled` : `${variant} enabled`,
110
- toggle && 'toggle',
111
- selected && 'selected',
132
+ isToggle && 'toggle',
133
+ isSelected && 'selected',
112
134
  morph.pressed && 'pressed',
113
135
  attributes.class,
114
136
  ]}
@@ -278,6 +300,9 @@
278
300
  outline-width: 3px;
279
301
  outline-offset: 2px;
280
302
  }
303
+ .enabled:focus-visible :global(.np-ripple-surface)::before {
304
+ opacity: var(--np-ripple-focus-opacity, 0.1);
305
+ }
281
306
  @media (prefers-reduced-motion: no-preference) {
282
307
  .enabled:focus-visible {
283
308
  animation: focusAnimation var(--np-motion-expressive-slow-effects) forwards;
@@ -316,8 +341,14 @@
316
341
  );
317
342
  }
318
343
  .tonal.toggle {
319
- color: var(--np-color-on-secondary-container);
320
- background-color: var(--np-color-secondary-container);
344
+ color: var(
345
+ --np-tonal-icon-button-unselected-icon-color,
346
+ var(--np-color-on-secondary-container)
347
+ );
348
+ background-color: var(
349
+ --np-tonal-icon-button-unselected-container-color,
350
+ var(--np-color-secondary-container)
351
+ );
321
352
  }
322
353
  .tonal.selected {
323
354
  color: var(--np-color-on-secondary);
@@ -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,12 +16,30 @@
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)}
@@ -48,6 +67,7 @@
48
67
  {/if}
49
68
  {#if !option.disabled}
50
69
  <Ripple />
70
+ <span class="np-touch"></span>
51
71
  {/if}
52
72
  {#if group !== undefined}
53
73
  {#if multiSelect}
@@ -93,11 +113,13 @@
93
113
  /* State layers take the content color of each variant and state. */
94
114
  --np-ripple-hover-color: currentColor;
95
115
  --np-ripple-pressed-color: currentColor;
96
- display: grid;
116
+ /* As wide as its labels need, every segment as wide as the widest. */
117
+ display: inline-grid;
118
+ box-sizing: border-box;
119
+ min-height: 2.5rem;
97
120
  color: var(--np-color-on-surface);
98
121
  border: 1px solid var(--np-color-outline);
99
122
  border-radius: var(--np-shape-corner-full);
100
- overflow-x: auto;
101
123
  }
102
124
 
103
125
  .np-segmented-buttons.np-error {
@@ -111,7 +133,7 @@
111
133
 
112
134
  .np-segmented-button {
113
135
  flex: 1;
114
- padding: 0.5rem 1.75rem;
136
+ padding: 0 1.75rem;
115
137
  text-align: center;
116
138
  cursor: pointer;
117
139
  font-size: 0.875rem;
@@ -157,12 +179,30 @@
157
179
  inset: 0;
158
180
  z-index: -1;
159
181
  opacity: 0;
182
+ border-radius: inherit;
160
183
  transition: opacity var(--np-motion-expressive-fast-effects);
161
184
  background-color: var(--np-color-secondary-container);
162
185
  }
163
186
  .width-icon,
164
187
  .np-segmented-button:has(input:checked) {
165
- padding: 0.5rem 0.75rem;
188
+ padding: 0 0.75rem;
189
+ }
190
+ /* The outer segments round off the container's ends, for their fill and state layer too. */
191
+ .np-segmented-button:first-child {
192
+ border-start-start-radius: var(--np-shape-corner-full);
193
+ border-end-start-radius: var(--np-shape-corner-full);
194
+ }
195
+ .np-segmented-button:last-child {
196
+ border-start-end-radius: var(--np-shape-corner-full);
197
+ border-end-end-radius: var(--np-shape-corner-full);
198
+ }
199
+ /* Each segment keeps a 48px tall target. */
200
+ .np-touch {
201
+ position: absolute;
202
+ inset-inline: 0;
203
+ top: 50%;
204
+ translate: 0 -50%;
205
+ height: max(3rem, 100%);
166
206
  }
167
207
  .np-segmented-button:has(input:checked) {
168
208
  color: var(--np-color-on-secondary-container);
@@ -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
- };
@@ -21,12 +21,3 @@ export const expandedWidths = (widths, index, expandedRatio, compressionLimit) =
21
21
  }
22
22
  return next;
23
23
  };
24
- export const parseMotion = (token) => {
25
- const [time, ...rest] = token.trim().split(/\s+/);
26
- const value = Number.parseFloat(time);
27
- const easing = rest.join(' ');
28
- return {
29
- duration: Number.isNaN(value) ? 0 : time.endsWith('ms') ? value : value * 1000,
30
- easing: easing || 'linear',
31
- };
32
- };
@@ -0,0 +1,10 @@
1
+ /** What a `ButtonGroup` decides for its buttons: their size and shape, and with `selection` which are selected. */
2
+ export interface ButtonGroupContext {
3
+ readonly selection: 'single' | 'multiple' | undefined;
4
+ readonly size: 'xs' | 's' | 'm' | 'l' | 'xl' | undefined;
5
+ readonly shape: 'round' | 'square' | undefined;
6
+ isSelected: (value: string) => boolean;
7
+ toggle: (value: string) => void;
8
+ }
9
+ export declare const setButtonGroupContext: (context: ButtonGroupContext) => ButtonGroupContext;
10
+ export declare const getButtonGroupContext: () => ButtonGroupContext | undefined;
@@ -0,0 +1,4 @@
1
+ import { getContext, setContext } from 'svelte';
2
+ const key = Symbol('np-button-group');
3
+ export const setButtonGroupContext = (context) => setContext(key, context);
4
+ export const getButtonGroupContext = () => getContext(key);
@@ -25,12 +25,31 @@ export interface IconButtonProps extends BaseButtonProps {
25
25
  }
26
26
  export interface ButtonGroupProps extends HTMLAttributes<HTMLDivElement> {
27
27
  variant?: 'standard' | 'connected';
28
+ /** Size of the buttons that don't set their own. */
29
+ size?: BaseButtonProps['size'];
30
+ /** Shape of the buttons that don't set their own. */
31
+ shape?: BaseButtonProps['shape'];
32
+ /**
33
+ * Lets the group manage selection: its buttons with a `value` become toggles, and `value`
34
+ * holds the selected one (`single`) or the selected ones (`multiple`).
35
+ */
36
+ selection?: 'single' | 'multiple';
37
+ value?: string | string[] | null;
38
+ /** With `selection`, keeps at least one button selected and makes a form require one. */
39
+ required?: boolean;
40
+ /** With `selection`, submits the selected values under this name, like a radio or checkbox group. */
41
+ name?: string;
42
+ form?: string;
43
+ /** Arrow keys move between the buttons, which share one tab stop. Off, Tab visits each. */
44
+ arrowKeys?: boolean;
28
45
  expandedRatio?: number;
29
46
  compressionLimit?: number;
30
47
  element?: HTMLElement;
31
48
  }
32
49
  export interface SplitButtonProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onclick'> {
33
50
  label?: string;
51
+ /** Shows only `icon` on the leading button. `label` then names it and becomes its tooltip. */
52
+ iconOnly?: boolean;
34
53
  icon?: Snippet;
35
54
  menu?: Snippet<[string]>;
36
55
  variant?: Exclude<ButtonProps['variant'], 'text'>;