fuma 2.1.6 → 2.5.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 (89) hide show
  1. package/dist/_doc/data.d.ts +3 -1
  2. package/dist/_doc/data.js +6 -2
  3. package/dist/command/index.d.ts +1 -1
  4. package/dist/command/index.js +1 -1
  5. package/dist/command/{command.svelte.js → useCommand.svelte.js} +1 -1
  6. package/dist/input/InputBoolean.svelte +41 -8
  7. package/dist/input/InputBoolean.svelte.d.ts +7 -0
  8. package/dist/input/InputCheckboxes.svelte +123 -0
  9. package/dist/input/InputCheckboxes.svelte.d.ts +19 -0
  10. package/dist/input/InputDateTime.svelte +199 -0
  11. package/dist/input/InputDateTime.svelte.d.ts +26 -0
  12. package/dist/input/InputMultiSelect.svelte +324 -0
  13. package/dist/input/{InputRelation.svelte.d.ts → InputMultiSelect.svelte.d.ts} +32 -13
  14. package/dist/input/InputNumber.svelte +43 -6
  15. package/dist/input/InputNumber.svelte.d.ts +8 -0
  16. package/dist/input/InputRadio.svelte +62 -24
  17. package/dist/input/InputRadio.svelte.d.ts +3 -0
  18. package/dist/input/InputRange.svelte +24 -4
  19. package/dist/input/InputRange.svelte.d.ts +9 -0
  20. package/dist/input/InputSelect.svelte +216 -62
  21. package/dist/input/InputSelect.svelte.d.ts +91 -6
  22. package/dist/input/InputSelectNative.svelte +51 -19
  23. package/dist/input/InputSelectNative.svelte.d.ts +8 -0
  24. package/dist/input/InputString.svelte +21 -6
  25. package/dist/input/InputString.svelte.d.ts +7 -0
  26. package/dist/input/InputTextarea.svelte +58 -11
  27. package/dist/input/InputTextarea.svelte.d.ts +14 -0
  28. package/dist/input/index.d.ts +4 -1
  29. package/dist/input/index.js +4 -1
  30. package/dist/input/useItems.svelte.d.ts +38 -0
  31. package/dist/input/useItems.svelte.js +142 -0
  32. package/dist/loading/Loading.svelte +3 -2
  33. package/dist/loading/Loading.svelte.d.ts +3 -2
  34. package/dist/popover/Popover.svelte +1 -2
  35. package/dist/popover/Popover.svelte.d.ts +13 -0
  36. package/dist/popover/index.d.ts +1 -1
  37. package/dist/popover/index.js +1 -1
  38. package/dist/popover/{popover.svelte.d.ts → usePopover.svelte.d.ts} +2 -1
  39. package/dist/popover/usePopover.svelte.js +145 -0
  40. package/dist/remote/useForm.d.ts +4 -5
  41. package/dist/remote/useForm.js +9 -4
  42. package/dist/state/index.d.ts +1 -1
  43. package/dist/state/index.js +1 -1
  44. package/dist/ui/button/ButtonDelete.svelte +6 -3
  45. package/dist/ui/button/ButtonDelete.svelte.d.ts +1 -1
  46. package/dist/ui/dialog/Dialog.svelte +5 -5
  47. package/dist/ui/drawer/Drawer.svelte +74 -26
  48. package/dist/ui/drawer/Drawer.svelte.d.ts +10 -4
  49. package/dist/ui/drawer/index.d.ts +1 -0
  50. package/dist/ui/drawer/index.js +1 -0
  51. package/dist/ui/drawer/inertBackground.d.ts +13 -0
  52. package/dist/ui/drawer/inertBackground.js +59 -0
  53. package/dist/ui/drawer/useLayer.svelte.d.ts +1 -1
  54. package/dist/ui/drawer/useLayer.svelte.js +11 -7
  55. package/dist/ui/index.d.ts +1 -0
  56. package/dist/ui/index.js +1 -0
  57. package/dist/ui/menu/ContextMenu.svelte +30 -17
  58. package/dist/ui/menu/ContextMenu.svelte.d.ts +2 -3
  59. package/dist/ui/menu/DropDown.svelte +1 -1
  60. package/dist/ui/menu/DropDown.svelte.d.ts +1 -1
  61. package/dist/ui/menu/DropDownMenu.svelte +12 -10
  62. package/dist/ui/menu/DropDownMenu.svelte.d.ts +2 -3
  63. package/dist/ui/menu/index.d.ts +0 -1
  64. package/dist/ui/menu/index.js +0 -2
  65. package/dist/ui/range/RangePicker.svelte +25 -13
  66. package/dist/ui/range/RangePicker.svelte.d.ts +9 -25
  67. package/dist/ui/range/RangePickerButton.svelte +49 -32
  68. package/dist/ui/range/RangePickerButton.svelte.d.ts +1 -1
  69. package/dist/ui/table/Table.svelte +16 -16
  70. package/dist/ui/table/Table.svelte.d.ts +1 -1
  71. package/dist/ui/table/TableFieldsEdition.svelte +25 -23
  72. package/dist/ui/table/TableHead.svelte +5 -1
  73. package/dist/ui/table/cell/TableCellBoolean.svelte +2 -2
  74. package/dist/ui/table/head/OrderButtons.svelte +21 -20
  75. package/dist/ui/table/head/OrderButtons.svelte.d.ts +2 -0
  76. package/dist/ui/table/head/TableHeadDate.svelte +66 -57
  77. package/dist/ui/table/head/TableHeadNumber.svelte +58 -22
  78. package/dist/ui/table/head/TableHeadSelect.svelte +31 -28
  79. package/dist/ui/table/head/TableHeadString.svelte +12 -8
  80. package/dist/ui/table/index.d.ts +1 -1
  81. package/dist/ui/table/index.js +1 -1
  82. package/package.json +7 -4
  83. package/dist/input/InputRelation.svelte +0 -200
  84. package/dist/popover/popover.svelte.js +0 -106
  85. package/dist/ui/table/TableViewSelect.svelte +0 -142
  86. package/dist/ui/table/TableViewSelect.svelte.d.ts +0 -11
  87. /package/dist/command/{command.svelte.d.ts → useCommand.svelte.d.ts} +0 -0
  88. /package/dist/state/{param.svelte.d.ts → param.d.ts} +0 -0
  89. /package/dist/state/{param.svelte.js → param.js} +0 -0
@@ -0,0 +1,324 @@
1
+ <script lang="ts" generics="Item">
2
+ import { CheckIcon, ChevronsUpDownIcon, SearchIcon, XIcon } from '@lucide/svelte'
3
+ import type { RemoteFormField } from '@sveltejs/kit'
4
+ import type { Snippet } from 'svelte'
5
+ import type { ClassValue } from 'svelte/elements'
6
+ import { slide } from 'svelte/transition'
7
+ import { tip } from '../action/tip.js'
8
+ import { useCommand } from '../command/useCommand.svelte.js'
9
+ import Loading from '../loading/Loading.svelte'
10
+ import { type PopoverType, usePopover } from '../popover/usePopover.svelte.js'
11
+ import Issues from './Issues.svelte'
12
+ import {
13
+ defaultGetLabel,
14
+ defaultGetValue,
15
+ type ItemsSource,
16
+ useItems
17
+ } from './useItems.svelte.js'
18
+
19
+ let {
20
+ label,
21
+ items,
22
+ getValue = defaultGetValue,
23
+ getLabel = (item) => defaultGetLabel(item, getValue),
24
+ filter,
25
+ searchable,
26
+ debounceMs,
27
+ selected = defaultSnippet,
28
+ proposal = defaultSnippet,
29
+ field,
30
+ value = $bindable([]),
31
+ placeholder = 'Selectionner des valeurs',
32
+ canReset = false,
33
+ class: klass,
34
+ disabled = false,
35
+ hint,
36
+ append,
37
+ labelAppend,
38
+ onSelect,
39
+ hotKey,
40
+ variant = 'block'
41
+ }: {
42
+ /** Un tableau déjà chargé, une fonction (synchrone ou non), ou une remote query. */
43
+ items: ItemsSource<Item>
44
+ label?: string
45
+ getValue?: (item: NoInfer<Item>) => string
46
+ /** Libellé affiché à défaut de snippet, et base du filtre d'une source tableau. */
47
+ getLabel?: (item: NoInfer<Item>) => string
48
+ /** Ne s'applique qu'à une source tableau: une fonction porte son propre filtre. */
49
+ filter?: (item: NoInfer<Item>, search: string) => boolean
50
+ /** Par défaut: toujours pour une fonction, au-delà de 7 entrées pour un tableau. */
51
+ searchable?: boolean
52
+ debounceMs?: number
53
+ selected?: Snippet<[Item]>
54
+ proposal?: Snippet<[Item, { isSelected: boolean; isFocus: boolean }]>
55
+ field?: RemoteFormField<string[]>
56
+ /**
57
+ * Les items, pas leurs valeurs soumises. Un parent qui les repilote après coup doit
58
+ * les lier par `bind:`: passé en simple prop, un `$bindable` cesse de suivre le
59
+ * parent dès que le composant y a écrit.
60
+ */
61
+ value?: NoInfer<Item>[]
62
+ placeholder?: string
63
+ canReset?: boolean
64
+ class?: ClassValue
65
+ /** Les valeurs restent soumises, mais la sélection ne peut plus changer. */
66
+ disabled?: boolean
67
+ hint?: Snippet<[Item[]]>
68
+ /** Rendu à droite du champ de recherche: une action « créer », typiquement. */
69
+ append?: Snippet<[PopoverType]>
70
+ /** Rendu à droite du libellé. Sans effet sur la variante `floating`. */
71
+ labelAppend?: Snippet
72
+ onSelect?: (items: NoInfer<Item>[], popover: PopoverType) => void
73
+ hotKey?: string
74
+ variant?: 'floating' | 'block'
75
+ } = $props()
76
+
77
+ const inputId = $props.id()
78
+ const listId = `${inputId}-list`
79
+ // `<label for>` ne nomme que les éléments étiquetables, ce qu'un `div` n'est pas: sans
80
+ // cette référence explicite, le combobox reste sans nom accessible.
81
+ const labelId = `${inputId}-label`
82
+ let trigger = $state<HTMLDivElement>()
83
+
84
+ let search = $state('')
85
+ // Une énumération courte n'a que faire d'un champ de recherche; une fonction, elle, ne
86
+ // peut pas s'en passer, puisque le composant ne sait pas la filtrer.
87
+ const isSearchable = $derived(searchable ?? (Array.isArray(items) ? items.length > 7 : true))
88
+ const query = useItems({
89
+ source: () => items,
90
+ search: () => search,
91
+ getLabel: (item) => getLabel(item),
92
+ filter: () => filter,
93
+ debounceMs: (() => debounceMs)()
94
+ })
95
+ const options = $derived(query.current ?? [])
96
+
97
+ // `value` porte les items et non les valeurs soumises: celles-ci s'en dérivent par
98
+ // `getValue`, alors que l'inverse demanderait une requête. C'est aussi ce qui rend
99
+ // l'état initial gratuit — pas de `defaultValue` à réconcilier avec le champ.
100
+ const selectedValues = $derived(value.map(getValue))
101
+
102
+ export const popover = usePopover({
103
+ listenFocus: false,
104
+ listenFocusout: true,
105
+ onShow: () => command.focusTrigger(),
106
+ // Le déclencheur est un `div`: `disabled` ne le neutralise pas comme un bouton, et
107
+ // le raccourci ouvrirait un popover qu'aucun geste ne peut plus atteindre.
108
+ hotKey: (() => (disabled ? undefined : hotKey))()
109
+ })
110
+ // Un multi-select ne se referme pas à chaque choix: `popover.hide()` n'est appelé
111
+ // que par le bouton de fermeture.
112
+ export const command = useCommand({
113
+ isEnable: () => popover.isOpen,
114
+ onSelect: (index) => {
115
+ const item = options[index]
116
+ if (item) toggle(item)
117
+ }
118
+ })
119
+
120
+ function toggle(item: Item) {
121
+ const itemValue = getValue(item)
122
+ value = selectedValues.includes(itemValue)
123
+ ? value.filter((selectedItem) => getValue(selectedItem) !== itemValue)
124
+ : [...value, item]
125
+ commit()
126
+ }
127
+
128
+ function remove(item: Item) {
129
+ const itemValue = getValue(item)
130
+ value = value.filter((selectedItem) => getValue(selectedItem) !== itemValue)
131
+ commit()
132
+ }
133
+
134
+ function commit() {
135
+ // Les cases cachées suffisent à soumettre; `set` garde la validation à jour.
136
+ field?.set(selectedValues)
137
+ // Un choix n'émet aucun évènement de formulaire: les cases sont écrites par le code,
138
+ // et le popover reste ouvert — rien ne rend donc la main au déclencheur, contrairement
139
+ // au select simple. Sans cette annonce, ce qui surveille le formulaire (une barre de
140
+ // sauvegarde) ne voit la sélection qu'au choix suivant.
141
+ trigger?.dispatchEvent(new Event('change', { bubbles: true }))
142
+ onSelect?.(value, popover)
143
+ }
144
+ </script>
145
+
146
+ {#snippet defaultSnippet(item: Item)}
147
+ <span>{getLabel(item)}</span>
148
+ {/snippet}
149
+
150
+ {#snippet triggerCombobox()}
151
+ <!-- Un `div` et non un `button`: les puces portent leur propre bouton de retrait, et
152
+ un bouton ne peut pas en contenir un autre. -->
153
+ <div
154
+ bind:this={trigger}
155
+ id={inputId}
156
+ role="combobox"
157
+ aria-controls={listId}
158
+ aria-labelledby={label ? labelId : undefined}
159
+ aria-expanded={popover.isOpen}
160
+ aria-haspopup="listbox"
161
+ aria-disabled={disabled}
162
+ tabindex={disabled ? -1 : 0}
163
+ class={[
164
+ 'input h-auto min-h-(--size) w-full flex-wrap items-center gap-1 py-1',
165
+ !disabled && 'cursor-pointer',
166
+ field?.issues()?.length && 'input-error',
167
+ klass
168
+ ]}
169
+ onkeydown={(event) => {
170
+ if (disabled) return
171
+ if (event.key !== 'Enter' && event.key !== ' ') return
172
+ event.preventDefault()
173
+ popover.show()
174
+ }}
175
+ {...disabled ? {} : popover.trigger}
176
+ {...isSearchable || disabled ? {} : command.trigger}
177
+ >
178
+ {#if !value.length}
179
+ <span class="opacity-60">{placeholder}</span>
180
+ {:else}
181
+ {#each value as item (getValue(item))}
182
+ <span
183
+ class="badge items-center gap-1 badge-sm"
184
+ transition:slide={{ axis: 'x', duration: 150 }}
185
+ >
186
+ <!-- item.icon not rerender if not wrapped in #key -->
187
+ {#key item}{@render selected(item)}{/key}
188
+ {#if !disabled}
189
+ <button
190
+ type="button"
191
+ class="btn btn-circle btn-ghost btn-xs"
192
+ aria-label="Retirer"
193
+ use:tip={{ content: 'Retirer' }}
194
+ onclick={(event) => {
195
+ // Sans quoi le clic remonte au combobox et rouvre le popover.
196
+ event.stopPropagation()
197
+ remove(item)
198
+ }}
199
+ >
200
+ <XIcon size={12} />
201
+ </button>
202
+ {/if}
203
+ </span>
204
+ {/each}
205
+ {/if}
206
+ <ChevronsUpDownIcon class="ml-auto shrink-0" size={14} opacity={0.7} />
207
+ </div>
208
+ {/snippet}
209
+
210
+ {#if field}
211
+ <!-- Une case cochée par valeur: c'est ainsi qu'un champ `string[]` se soumet. Le
212
+ `checked` explicite prime sur celui de `as()`, qui lit `field.value()` — sans lui
213
+ la sélection initiale ne partirait pas tant que rien n'a été cliqué. -->
214
+ {#each selectedValues as selectedValue (selectedValue)}
215
+ <input {...field.as('checkbox', selectedValue)} checked class="hidden" />
216
+ {/each}
217
+ {/if}
218
+
219
+ <div class="contents">
220
+ {#if !label}
221
+ {@render triggerCombobox()}
222
+ {:else if variant === 'floating'}
223
+ <label class="floating-label">
224
+ <span id={labelId}>{label}</span>
225
+ {@render triggerCombobox()}
226
+ </label>
227
+ {:else}
228
+ <fieldset class="fieldset">
229
+ <label id={labelId} class="label text-wrap" for={inputId}>
230
+ <span>{label}</span>
231
+ {@render labelAppend?.()}
232
+ </label>
233
+ {@render triggerCombobox()}
234
+ </fieldset>
235
+ {/if}
236
+
237
+ <div
238
+ {...popover.content}
239
+ class={['my-2 rounded-box border bg-base-100 shadow-xl']}
240
+ style="min-width: anchor-size(width);"
241
+ tabindex="-1"
242
+ >
243
+ {#if isSearchable || (canReset && value.length) || append}
244
+ <div class="sticky top-0 z-10 flex gap-2 bg-base-100/10 p-2 backdrop-blur-md">
245
+ {#if isSearchable}
246
+ <label class="input grow input-ghost input-sm">
247
+ <SearchIcon opacity={0.6} size={20} />
248
+ <input
249
+ type="search"
250
+ placeholder="Recherche"
251
+ autocomplete="off"
252
+ bind:value={search}
253
+ {...command.trigger}
254
+ />
255
+ </label>
256
+ {:else}
257
+ <div class="grow"></div>
258
+ {/if}
259
+ {#if canReset && value.length}
260
+ <button
261
+ class="btn btn-square btn-soft btn-sm"
262
+ type="button"
263
+ onclick={() => {
264
+ value = []
265
+ commit()
266
+ popover.hide()
267
+ }}
268
+ use:tip={{ content: 'Tout désélectionner' }}
269
+ >
270
+ <XIcon />
271
+ </button>
272
+ {/if}
273
+ {@render append?.(popover)}
274
+ </div>
275
+ {/if}
276
+
277
+ <ul
278
+ id={listId}
279
+ role="listbox"
280
+ aria-multiselectable="true"
281
+ class={['menu max-h-80 w-full flex-nowrap', (isSearchable || append) && 'pt-0']}
282
+ >
283
+ {#each options as item, index (item)}
284
+ {@const isSelected = selectedValues.includes(getValue(item))}
285
+ {@const isFocus = index === command.focusIndex}
286
+ <li>
287
+ <button
288
+ {...command.item(index, '')}
289
+ class={[isSelected && 'border', isFocus && 'menu-focus', 'scroll-mt-13 scroll-mb-2']}
290
+ type="button"
291
+ tabindex="-1"
292
+ role="option"
293
+ aria-selected={isSelected}
294
+ >
295
+ {@render proposal(item, { isSelected, isFocus })}
296
+ {#if proposal === defaultSnippet && isSelected}
297
+ <CheckIcon class="ml-auto" size={18} />
298
+ {/if}
299
+ </button>
300
+ </li>
301
+ {/each}
302
+ </ul>
303
+
304
+ <Loading {query} />
305
+ </div>
306
+ <Issues {field} />
307
+ {@render hint?.(value)}
308
+ </div>
309
+
310
+ <style>
311
+ div[popover] {
312
+ border-color: color-mix(in oklab, var(--color-base-content) 20%, #0000);
313
+ }
314
+
315
+ /* Le déclencheur est un `div`: ni `:disabled` ni `[disabled]` ne s'y appliquent, et le
316
+ style désactivé de daisyUI passe à côté. Reproduit ici sur `aria-disabled`, le seul
317
+ marqueur valide sur un `role="combobox"`. */
318
+ div[aria-disabled='true'] {
319
+ cursor: not-allowed;
320
+ background-color: var(--color-base-200);
321
+ border-color: var(--color-base-200);
322
+ box-shadow: none;
323
+ }
324
+ </style>
@@ -1,27 +1,46 @@
1
- import type { RemoteFormField, RemoteQueryFunction } from '@sveltejs/kit';
1
+ import type { RemoteFormField } from '@sveltejs/kit';
2
2
  import type { Snippet } from 'svelte';
3
3
  import type { ClassValue } from 'svelte/elements';
4
- import { type PopoverType } from '../popover/popover.svelte.js';
4
+ import { type PopoverType } from '../popover/usePopover.svelte.js';
5
+ import { type ItemsSource } from './useItems.svelte.js';
5
6
  declare function $$render<Item>(): {
6
7
  props: {
7
- searchItems: RemoteQueryFunction<{
8
- search: string;
9
- }, Item[]>;
8
+ /** Un tableau déjà chargé, une fonction (synchrone ou non), ou une remote query. */
9
+ items: ItemsSource<Item>;
10
10
  label?: string;
11
11
  getValue?: (item: NoInfer<Item>) => string;
12
+ /** Libellé affiché à défaut de snippet, et base du filtre d'une source tableau. */
13
+ getLabel?: (item: NoInfer<Item>) => string;
14
+ /** Ne s'applique qu'à une source tableau: une fonction porte son propre filtre. */
15
+ filter?: (item: NoInfer<Item>, search: string) => boolean;
16
+ /** Par défaut: toujours pour une fonction, au-delà de 7 entrées pour un tableau. */
17
+ searchable?: boolean;
18
+ debounceMs?: number;
12
19
  selected?: Snippet<[Item]>;
13
20
  proposal?: Snippet<[Item, {
14
21
  isSelected: boolean;
15
22
  isFocus: boolean;
16
23
  }]>;
17
- field?: RemoteFormField<string>;
18
- value?: string;
24
+ field?: RemoteFormField<string[]>;
25
+ /**
26
+ * Les items, pas leurs valeurs soumises. Un parent qui les repilote après coup doit
27
+ * les lier par `bind:`: passé en simple prop, un `$bindable` cesse de suivre le
28
+ * parent dès que le composant y a écrit.
29
+ */
30
+ value?: NoInfer<Item>[];
19
31
  placeholder?: string;
32
+ canReset?: boolean;
20
33
  class?: ClassValue;
21
- nullable?: boolean;
22
- hint?: Snippet<[Item | undefined]>;
23
- onSelect?: (item: NoInfer<Item> | undefined, popover: PopoverType) => void;
34
+ /** Les valeurs restent soumises, mais la sélection ne peut plus changer. */
35
+ disabled?: boolean;
36
+ hint?: Snippet<[Item[]]>;
37
+ /** Rendu à droite du champ de recherche: une action « créer », typiquement. */
38
+ append?: Snippet<[PopoverType]>;
39
+ /** Rendu à droite du libellé. Sans effet sur la variante `floating`. */
40
+ labelAppend?: Snippet;
41
+ onSelect?: (items: NoInfer<Item>[], popover: PopoverType) => void;
24
42
  hotKey?: string;
43
+ variant?: "floating" | "block";
25
44
  };
26
45
  exports: {
27
46
  popover: {
@@ -90,6 +109,6 @@ interface $$IsomorphicComponent {
90
109
  <Item>(internal: unknown, props: ReturnType<__sveltets_Render<Item>['props']> & {}): ReturnType<__sveltets_Render<Item>['exports']>;
91
110
  z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
92
111
  }
93
- declare const InputRelation: $$IsomorphicComponent;
94
- type InputRelation<Item> = InstanceType<typeof InputRelation<Item>>;
95
- export default InputRelation;
112
+ declare const InputMultiSelect: $$IsomorphicComponent;
113
+ type InputMultiSelect<Item> = InstanceType<typeof InputMultiSelect<Item>>;
114
+ export default InputMultiSelect;
@@ -2,29 +2,66 @@
2
2
  import type { RemoteFormField } from '@sveltejs/kit'
3
3
  import Issues from './Issues.svelte'
4
4
  import type { InputProps } from './type.js'
5
+ import type { Snippet } from 'svelte'
5
6
 
6
7
  let {
7
8
  label,
8
9
  field,
9
10
  value = $bindable(),
10
11
  class: klass,
12
+ variant = 'block',
13
+ labelAppend,
11
14
  ...props
12
15
  }: {
13
16
  label: string
14
17
  field?: RemoteFormField<number>
18
+ /**
19
+ * Sans `field`, la valeur liée. Avec, la valeur initiale du champ — celui-ci prend
20
+ * ensuite le relais et devient la source de vérité.
21
+ */
15
22
  value?: number
23
+ variant?: 'floating' | 'block'
24
+ /** Rendu à droite du libellé. Sans effet sur la variante `floating`. */
25
+ labelAppend?: Snippet
16
26
  } & InputProps = $props()
27
+
28
+ const inputId = $props.id()
29
+ const inputProps = $derived({
30
+ id: inputId,
31
+ class: 'input w-full',
32
+ placeholder: variant === 'floating' ? label : '',
33
+ ...props
34
+ })
17
35
  </script>
18
36
 
19
- <label class="floating-label">
20
- <span class="label">{label}</span>
37
+ {#snippet snippetInput()}
21
38
  {#if field}
22
- <input placeholder={label} class={['input', klass]} {...field.as('number')} {...props} />
39
+ {#if value === undefined}
40
+ <input {...inputProps} {...field.as('number')} />
41
+ {:else}
42
+ <input {...inputProps} {...field.as('number', value)} />
43
+ {/if}
23
44
  {:else}
24
- <input placeholder={label} class={['input', klass]} type="number" bind:value {...props} />
45
+ <input {...inputProps} type="number" bind:value />
25
46
  {/if}
26
- <Issues {field} />
27
- </label>
47
+ {/snippet}
48
+
49
+ {#if variant === 'floating'}
50
+ <label class={['floating-label', klass]}>
51
+ <span>{label}</span>
52
+ {@render snippetInput()}
53
+ <Issues {field} />
54
+ </label>
55
+ {:else}
56
+ <fieldset class={['fieldset', klass]}>
57
+ <label class="label text-wrap" for={inputId}>
58
+ <span>{label}</span>
59
+ {@render labelAppend?.()}
60
+ </label>
61
+ {@render snippetInput()}
62
+ <Issues {field} />
63
+ </fieldset>
64
+ {/if}
28
65
 
29
66
  <style>
30
67
  input[aria-invalid='true'] {
@@ -1,9 +1,17 @@
1
1
  import type { RemoteFormField } from '@sveltejs/kit';
2
2
  import type { InputProps } from './type.js';
3
+ import type { Snippet } from 'svelte';
3
4
  type $$ComponentProps = {
4
5
  label: string;
5
6
  field?: RemoteFormField<number>;
7
+ /**
8
+ * Sans `field`, la valeur liée. Avec, la valeur initiale du champ — celui-ci prend
9
+ * ensuite le relais et devient la source de vérité.
10
+ */
6
11
  value?: number;
12
+ variant?: 'floating' | 'block';
13
+ /** Rendu à droite du libellé. Sans effet sur la variante `floating`. */
14
+ labelAppend?: Snippet;
7
15
  } & InputProps;
8
16
  declare const InputNumber: import("svelte").Component<$$ComponentProps, {}, "value">;
9
17
  type InputNumber = ReturnType<typeof InputNumber>;
@@ -2,47 +2,75 @@
2
2
  import type { RemoteFormField } from '@sveltejs/kit'
3
3
  import { type Options, parseOptions } from '../utils/index.js'
4
4
  import Issues from './Issues.svelte'
5
+ import type { Snippet } from 'svelte'
5
6
 
6
7
  let {
7
8
  field,
8
9
  label,
9
10
  value = $bindable(),
10
- options: optionsProp
11
+ options: optionsProp,
12
+ labelAppend
11
13
  }: {
12
14
  field?: RemoteFormField<string>
13
15
  label: string
14
16
  value?: string
15
17
  options: Options
18
+ /** Rendu à droite du libellé. */
19
+ labelAppend?: Snippet
16
20
  } = $props()
17
21
 
18
22
  const options = $derived(parseOptions(optionsProp))
19
23
  </script>
20
24
 
21
25
  <div class="flex flex-col gap-1">
22
- <span class="label px-3 text-sm">{label}</span>
26
+ <span class="label text-xs text-wrap">
27
+ <span>{label}</span>
28
+ {@render labelAppend?.()}
29
+ </span>
23
30
  <div class="join join-vertical">
24
31
  {#each options as option (option.value)}
25
32
  {@const { class: klass, ...props } = option}
26
- <label class="input join-item not-disabled:cursor-pointer not-disabled:hover:bg-base-200">
27
- {#if option.icon}
28
- <option.icon size={18} opacity={0.8} />
29
- {/if}
30
- <span>{option.label}</span>
31
- {#if field}
32
- <input
33
- {...field.as('radio', option.value)}
34
- class={['radio ml-auto radio-sm', klass]}
35
- {...props}
36
- />
37
- {:else}
38
- <input
39
- type="radio"
40
- checked={value === option.value}
41
- onchange={() => (value = option.value)}
42
- class={['radio ml-auto radio-sm', klass]}
43
- {...props}
44
- />
45
- {/if}
33
+ {@const checked = field?.value() === option.value || value === option.value}
34
+ <label
35
+ class={[
36
+ 'input join-item not-disabled:cursor-pointer not-disabled:hover:bg-base-200',
37
+ 'group h-auto min-h-10'
38
+ ]}
39
+ >
40
+ <div class="flex h-(--size) grow items-center gap-2">
41
+ {#if option.icon}
42
+ <option.icon size={18} opacity={0.8} />
43
+ {/if}
44
+ <span class="grow">{option.label}</span>
45
+ {#if field}
46
+ <input {...field.as('radio', option.value)} class={['w-0']} {...props} />
47
+ {:else}
48
+ <input
49
+ type="radio"
50
+ {checked}
51
+ onchange={() => (value = option.value)}
52
+ class={['w-0']}
53
+ {...props}
54
+ />
55
+ {/if}
56
+ <div
57
+ class={[
58
+ 'ml-auto h-5 w-5 rounded-full outline',
59
+ 'bg-base-100',
60
+ !checked && 'outline-base-content/20',
61
+ klass
62
+ ]}
63
+ >
64
+ <div
65
+ class={[
66
+ 'grid h-5 w-5 place-content-center rounded-full',
67
+ 'bg-base-content',
68
+ 'ease scale-0 opacity-0',
69
+ checked && 'scale-[80%] opacity-100'
70
+ ]}
71
+ ></div>
72
+ </div>
73
+ </div>
46
74
  </label>
47
75
  {/each}
48
76
  </div>
@@ -50,7 +78,17 @@
50
78
  </div>
51
79
 
52
80
  <style>
53
- input {
54
- --input-color: var(--color-base-content);
81
+ .ease {
82
+ transition-property: translate background-color opacity;
83
+ transition-timing-function: cubic-bezier(0.275, 0.485, 0.515, 1.45);
84
+ transition-duration: 200ms;
85
+ }
86
+
87
+ label:has(input[aria-invalid='true']) {
88
+ --input-color: var(--color-error);
89
+ }
90
+ label:has(input:disabled) {
91
+ cursor: not-allowed;
92
+ border-color: var(--input-color);
55
93
  }
56
94
  </style>
@@ -1,10 +1,13 @@
1
1
  import type { RemoteFormField } from '@sveltejs/kit';
2
2
  import { type Options } from '../utils/index.js';
3
+ import type { Snippet } from 'svelte';
3
4
  type $$ComponentProps = {
4
5
  field?: RemoteFormField<string>;
5
6
  label: string;
6
7
  value?: string;
7
8
  options: Options;
9
+ /** Rendu à droite du libellé. */
10
+ labelAppend?: Snippet;
8
11
  };
9
12
  declare const InputRadio: import("svelte").Component<$$ComponentProps, {}, "value">;
10
13
  type InputRadio = ReturnType<typeof InputRadio>;
@@ -2,29 +2,49 @@
2
2
  import type { RemoteFormField } from '@sveltejs/kit'
3
3
  import Issues from './Issues.svelte'
4
4
  import type { InputProps } from './type.js'
5
+ import type { Snippet } from 'svelte'
5
6
 
6
7
  let {
7
8
  label,
8
9
  field,
9
10
  value = $bindable(),
11
+ name,
12
+ labelAppend,
10
13
  class: klass,
11
14
  ...props
12
15
  }: {
13
16
  label: string
14
17
  field?: RemoteFormField<number>
18
+ /**
19
+ * Sans `field`, la valeur liée. Avec, la valeur initiale du champ — celui-ci prend
20
+ * ensuite le relais et devient la source de vérité.
21
+ */
15
22
  value?: number
23
+ /** Sans `field`, ce qui nomme la valeur soumise. Avec, `field.as()` s'en charge. */
24
+ name?: string
25
+ /** Rendu à droite du libellé, avant la valeur courante. */
26
+ labelAppend?: Snippet
16
27
  } & InputProps = $props()
17
28
  </script>
18
29
 
19
30
  <label>
20
31
  <div class="flex gap-2 px-3 text-sm">
21
- <span class="label">{label}</span>
22
- <span class="ml-auto">{field?.value() ?? value}</span>
32
+ <span class="label text-wrap">{label}</span>
33
+ {@render labelAppend?.()}
34
+ <span class="label ml-auto">{field?.value() ?? value}</span>
23
35
  </div>
24
36
  {#if field}
25
- <input class={['range range-sm', klass]} {...field.as('range')} {...props} />
37
+ <!-- `props` avant `field.as()`: le navigateur ramène la valeur d'un `range` sur la grille
38
+ `min`/`max`/`step` à chaque écriture. Posée en premier, elle est arrondie selon les
39
+ bornes par défaut (0–100, pas de 1), et poser les vraies bornes ensuite l'y recale une
40
+ seconde fois — au montage côté client, 0.75 finissait sur le minimum. -->
41
+ <input
42
+ class={['range w-full range-sm', klass]}
43
+ {...props}
44
+ {...value === undefined ? field.as('range') : field.as('range', value)}
45
+ />
26
46
  {:else}
27
- <input class={['range range-sm', klass]} type="range" bind:value {...props} />
47
+ <input class={['range w-full range-sm', klass]} type="range" {name} bind:value {...props} />
28
48
  {/if}
29
49
  <Issues {field} />
30
50
  </label>