@dosgato/dialog 1.5.9 → 1.6.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.
@@ -6,14 +6,28 @@
6
6
  export let value: boolean
7
7
  export let onChange: any = undefined
8
8
  export let onBlur: any = undefined
9
+ export let onKeydown: any = undefined
9
10
  export let descid: string | undefined = undefined
10
11
  export let disabled = false
11
12
  export let valid = false
12
13
  export let invalid = false
13
14
  export let inputelement: HTMLInputElement = undefined as any
15
+ export let indeterminate = false
16
+ export let tabindex: number | undefined = undefined
17
+
18
+ $: if (inputelement) {
19
+ if (!indeterminate && inputelement.indeterminate) {
20
+ inputelement.style.setProperty('--cb-transition', 'none')
21
+ inputelement.indeterminate = false
22
+ // restore after one frame so checked transitions still animate
23
+ requestAnimationFrame(() => { inputelement.style.removeProperty('--cb-transition') })
24
+ } else {
25
+ inputelement.indeterminate = indeterminate
26
+ }
27
+ }
14
28
  </script>
15
29
 
16
- <input bind:this={inputelement} {id} type="checkbox" {name} class:valid class:invalid {disabled} aria-describedby={descid} bind:checked={value} on:change={onChange} on:blur={onBlur}>
30
+ <input bind:this={inputelement} {id} type="checkbox" {name} class:valid class:invalid {disabled} aria-describedby={descid} {tabindex} bind:checked={value} on:change={onChange} on:blur={onBlur} on:keydown={onKeydown}>
17
31
 
18
32
  <style>
19
33
  input, input:before, input:after {
@@ -45,7 +59,7 @@ input[type="checkbox"]::before {
45
59
  clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%);
46
60
  transform: scale(0);
47
61
  transform-origin: bottom left;
48
- transition: 120ms transform ease-in-out;
62
+ transition: var(--cb-transition, 120ms transform ease-in-out);
49
63
  box-shadow: inset 1em 1em var(--dialog-checkbox-color, currentColor);
50
64
  /* Windows High Contrast Mode */
51
65
  background-color: CanvasText;
@@ -53,6 +67,12 @@ input[type="checkbox"]::before {
53
67
 
54
68
  input[type="checkbox"]:checked::before {
55
69
  transform: scale(1);
70
+ transition: 120ms transform ease-in-out;
71
+ }
72
+
73
+ input[type="checkbox"]:indeterminate::before {
74
+ clip-path: polygon(10% 40%, 10% 60%, 90% 60%, 90% 40%);
75
+ transform: scale(1);
56
76
  }
57
77
 
58
78
  input[type="checkbox"]:focus {
@@ -6,11 +6,14 @@ declare const __propDef: {
6
6
  value: boolean;
7
7
  onChange?: any;
8
8
  onBlur?: any;
9
+ onKeydown?: any;
9
10
  descid?: string | undefined;
10
11
  disabled?: boolean;
11
12
  valid?: boolean;
12
13
  invalid?: boolean;
13
14
  inputelement?: HTMLInputElement;
15
+ indeterminate?: boolean;
16
+ tabindex?: number | undefined;
14
17
  };
15
18
  events: {
16
19
  [evt: string]: CustomEvent<any>;
@@ -3,13 +3,19 @@
3
3
  choices than the available width of the `<div>` will support then it will create multiple rows to render within using
4
4
  flex. Ordering is top down by default but can be order horizontally by toggling `leftToRight`.
5
5
  The value of the field will be an array corresponding to the values of the checkboxes that are checked.
6
+
7
+ Choices may optionally include a `group` property. When any choices have a group, they are rendered in fieldsets
8
+ with the group string as the legend. Choices without a group render ungrouped. All choices still write to the
9
+ same field path. `selectAll` adds a global select-all checkbox. `groupSelect` adds per-group select-all
10
+ checkboxes. The two props are independent and can be used separately or together.
6
11
  -->
7
12
  <script lang="ts">
8
13
  import { getContext } from 'svelte'
9
14
  import { Field, FORM_CONTEXT, arraySerialize, FORM_INHERITED_PATH } from '@txstate-mws/svelte-forms'
10
15
  import type { FormStore } from '@txstate-mws/svelte-forms'
11
16
  import { derivedStore } from '@txstate-mws/svelte-store'
12
- import { isNotBlank, randomid } from 'txstate-utils'
17
+ import { isNotBlank, randomid, sortby } from 'txstate-utils'
18
+ import { modifierKey, ScreenReaderOnly } from '@txstate-mws/svelte-components'
13
19
  import Container from './Container.svelte'
14
20
  import Checkbox from './Checkbox.svelte'
15
21
  import { getDescribedBy } from './helpers'
@@ -19,7 +25,7 @@
19
25
  export let id: string | undefined = undefined
20
26
  export let path: string
21
27
  export let label = ''
22
- export let choices: { label?: string, value: any, disabled?: boolean }[] | undefined
28
+ export let choices: { label?: string, value: any, disabled?: boolean, group?: string }[] | undefined
23
29
  export let defaultValue: any = []
24
30
  export let conditional: boolean | undefined = undefined
25
31
  export let maxwidth = 250
@@ -28,6 +34,7 @@
28
34
  export let extradescid: string | undefined = undefined
29
35
  export let helptext: string | undefined = undefined
30
36
  export let selectAll = false
37
+ export let groupSelect = false
31
38
 
32
39
  const store = getContext<FormStore>(FORM_CONTEXT)
33
40
  const inheritedPath = getContext<string>(FORM_INHERITED_PATH)
@@ -36,6 +43,7 @@
36
43
  const currentWidth = derivedStore(store, 'width')
37
44
  $: cols = Math.min(Math.ceil($currentWidth / maxwidth), choices?.length ?? 0)
38
45
 
46
+ // Flat layout (used when no groups)
39
47
  let orders: number[]
40
48
  let width = '100%'
41
49
  function redoLayout (..._: any) {
@@ -45,11 +53,17 @@
45
53
  }
46
54
  $: redoLayout(choices, cols)
47
55
 
56
+ $: choiceOrder = new Map(choices?.map((c, i) => [c.value, i]) ?? [])
57
+
58
+ function sortByChoiceOrder (values: any[]) {
59
+ return sortby(values, v => choiceOrder.get(v) ?? 0)
60
+ }
61
+
48
62
  function onChangeCheckbox (setVal: (val: any) => void, choice: NonNullable<typeof choices>[number], included: boolean) {
49
63
  setVal(v => {
50
64
  if (v == null) return included ? [] : [choice.value]
51
65
  if (included) return v.filter(s => s !== choice.value)
52
- else return [...v, choice.value]
66
+ else return sortByChoiceOrder([...v, choice.value])
53
67
  })
54
68
  }
55
69
 
@@ -59,42 +73,203 @@
59
73
  }
60
74
  $: reactToValue($val)
61
75
 
62
- let selectAllElement: HTMLInputElement | undefined
76
+ // Grouping
77
+ $: hasGroups = choices?.some(c => c.group) ?? false
78
+
79
+ let groups: { name: string, items: { choice: NonNullable<typeof choices>[number], originalIndex: number }[] }[] = []
80
+ let groupLayouts = new Map<string, { width: string, orders: number[] }>()
81
+
82
+ function computeGroups (..._: any) {
83
+ if (!hasGroups || !choices) { groups = []; return }
84
+ // eslint-disable-next-line svelte/prefer-svelte-reactivity -- already handling reactivity (maintain svelte 4 compat)
85
+ const map = new Map<string, typeof groups[number]['items']>()
86
+ for (let i = 0; i < choices.length; i++) {
87
+ const key = choices[i].group ?? ''
88
+ if (!map.has(key)) map.set(key, [])
89
+ map.get(key)!.push({ choice: choices[i], originalIndex: i })
90
+ }
91
+ groups = Array.from(map.entries()).map(([name, items]) => ({ name, items }))
92
+ }
93
+ $: computeGroups(choices, hasGroups)
94
+
95
+ function computeGroupLayouts (..._: any) {
96
+ // eslint-disable-next-line svelte/prefer-svelte-reactivity -- already handling reactivity (maintain svelte 4 compat)
97
+ const layouts = new Map<string, { width: string, orders: number[] }>()
98
+ const maxCols = Math.max(...groups.map(g => Math.min(Math.ceil($currentWidth / maxwidth), g.items.length)), 1)
99
+ const w = `${100 / maxCols}%`
100
+ for (const group of groups) {
101
+ const count = group.items.length
102
+ const rows = Math.ceil(count / maxCols)
103
+ const ords = group.items.map((_, i) =>
104
+ leftToRight ? i : Math.floor((i + 1) / maxCols) + rows * (i % maxCols)
105
+ )
106
+ layouts.set(group.name, { width: w, orders: ords })
107
+ }
108
+ groupLayouts = layouts
109
+ }
110
+ $: computeGroupLayouts(groups, $currentWidth)
111
+
112
+ // Select all
63
113
  const selectAllId = randomid()
64
114
 
65
115
  $: selectAllChecked = choices?.every(choice => choice.disabled || selected.has(choice.value)) ?? false
116
+ $: selectAllIndeterminate = !selectAllChecked && (choices?.some(choice => !choice.disabled && selected.has(choice.value)) ?? false)
66
117
 
67
118
  function selectAllChanged () {
68
119
  if (selectAllChecked) {
69
- // it was checked and is now unchecked, clear it out
70
120
  void store.setField(finalPath, [])
71
121
  } else {
72
- // it was not checked and now it is checked
73
122
  void store.setField(finalPath, choices?.filter(choice => !choice.disabled).map(choice => choice.value) ?? [])
74
123
  }
75
124
  }
76
125
 
126
+ // Per-group select all
127
+ // selected must be passed explicitly so Svelte tracks it as a template dependency
128
+ function isGroupAllChecked (items: typeof groups[number]['items'], sel: Set<any>): boolean {
129
+ return items.length > 0 && items.every(item => item.choice.disabled || sel.has(item.choice.value))
130
+ }
131
+
132
+ function isGroupIndeterminate (items: typeof groups[number]['items'], sel: Set<any>): boolean {
133
+ const enabledItems = items.filter(item => !item.choice.disabled)
134
+ const selectedCount = enabledItems.filter(item => sel.has(item.choice.value)).length
135
+ return selectedCount > 0 && selectedCount < enabledItems.length
136
+ }
137
+
138
+ function groupSelectChanged (items: typeof groups[number]['items']) {
139
+ const allChecked = isGroupAllChecked(items, selected)
140
+ const groupValues = items.filter(item => !item.choice.disabled).map(item => item.choice.value)
141
+ if (allChecked) {
142
+ const removing = new Set(groupValues)
143
+ void store.setField(finalPath, [...selected].filter(v => !removing.has(v)))
144
+ } else {
145
+ // eslint-disable-next-line svelte/prefer-svelte-reactivity -- already handling reactivity (maintain svelte 4 compat)
146
+ const newSelected = new Set(selected)
147
+ for (const v of groupValues) newSelected.add(v)
148
+ void store.setField(finalPath, sortByChoiceOrder(Array.from(newSelected)))
149
+ }
150
+ }
151
+
152
+ // Roving tabindex - flat list of focusable checkbox DOM ids
153
+ let focusableIds: string[] = []
154
+ let activeId = ''
155
+ const groupCheckboxIds: Record<string, string> = {}
156
+ const groupLegendIds: Record<string, string> = {}
157
+
158
+ function getGroupCheckboxId (groupName: string) {
159
+ groupCheckboxIds[groupName] ??= randomid()
160
+ return groupCheckboxIds[groupName]
161
+ }
162
+
163
+ function getGroupLegendId (groupName: string) {
164
+ groupLegendIds[groupName] ??= randomid()
165
+ return groupLegendIds[groupName]
166
+ }
167
+
168
+ function computeFocusables (..._: any) {
169
+ const ids: string[] = []
170
+ if (selectAll) ids.push(selectAllId)
171
+ if (hasGroups) {
172
+ for (const group of groups) {
173
+ if (group.name && (selectAll || groupSelect)) ids.push(getGroupCheckboxId(group.name))
174
+ for (const item of group.items) {
175
+ if (!item.choice.disabled) ids.push(`${finalPath}.${item.originalIndex}`)
176
+ }
177
+ }
178
+ } else {
179
+ for (let i = 0; i < (choices?.length ?? 0); i++) {
180
+ if (!choices![i].disabled) ids.push(`${finalPath}.${i}`)
181
+ }
182
+ }
183
+ focusableIds = ids
184
+ if (!activeId || !ids.includes(activeId)) {
185
+ activeId = ids[0] ?? ''
186
+ }
187
+ }
188
+ $: computeFocusables(choices, groups, hasGroups, selectAll, groupSelect)
189
+
190
+ function onKeydown (e: KeyboardEvent) {
191
+ if (modifierKey(e)) return
192
+ let direction = 0
193
+ if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') direction = -1
194
+ else if (e.key === 'ArrowDown' || e.key === 'ArrowRight') direction = 1
195
+ if (!direction) return
196
+ e.preventDefault()
197
+ const idx = focusableIds.indexOf(activeId)
198
+ const next = idx + direction
199
+ if (next >= 0 && next < focusableIds.length) {
200
+ activeId = focusableIds[next]
201
+ document.getElementById(activeId)?.focus()
202
+ }
203
+ }
204
+
205
+ function tabindexFor (domId: string): number {
206
+ return activeId === domId ? 0 : -1
207
+ }
208
+
77
209
  const descid = randomid()
78
210
  </script>
79
211
 
80
212
  <Field {path} {defaultValue} allowedValues={choices?.map(choice => choice.value)} allowedValuesMultiple {conditional} let:path={fullpath} let:value let:onBlur let:setVal let:messages let:valid let:invalid serialize={arraySerialize}>
81
213
  <Container path={fullpath} {id} {label} {messages} {descid} {related} {helptext} let:messagesid let:helptextid>
82
- <div class="dialog-choices {className}" class:valid class:invalid>
214
+ <div class="dialog-choices {className}" class:valid class:invalid role="listbox" aria-multiselectable={true} aria-label={label}>
83
215
  {#if selectAll}
84
- <label for={selectAllId} style:width>
85
- <Checkbox id={selectAllId} name={selectAllId} bind:inputelement={selectAllElement} value={selectAllChecked} onChange={selectAllChanged} />
216
+ <label for={selectAllId} style:width={hasGroups ? '100%' : width}>
217
+ <Checkbox id={selectAllId} name={selectAllId} value={selectAllChecked} indeterminate={selectAllIndeterminate} onChange={selectAllChanged} {onKeydown} tabindex={tabindexFor(selectAllId)} />
86
218
  <span>Select All</span>
87
219
  </label>
88
220
  {/if}
89
- {#each choices as choice, idx (choice.value)}
90
- {@const checkid = `${fullpath}.${idx}`}
91
- {@const included = value?.includes(choice.value)}
92
- {@const label = choice.label || (typeof choice.value === 'string' ? choice.value : '')}
93
- <label for={checkid} style:width style:order={orders[idx]}>
94
- <Checkbox id={checkid} name={checkid} value={included} descid={getDescribedBy([descid, messagesid, helptextid, extradescid])} disabled={choice.disabled} onChange={() => onChangeCheckbox(setVal, choice, included)} {onBlur} />
95
- <span>{label}</span>
96
- </label>
97
- {/each}
221
+ {#if hasGroups}
222
+ {#each groups as group (group.name)}
223
+ {@const layout = groupLayouts.get(group.name)}
224
+ {#if group.name}
225
+ {@const groupLegendId = getGroupLegendId(group.name)}
226
+ <div class="dialog-choices-group">
227
+ {#if selectAll || groupSelect}
228
+ {@const groupCkId = getGroupCheckboxId(group.name)}
229
+ <label class="dialog-choices-group-header">
230
+ <Checkbox id={groupCkId} name="group-{group.name}" value={isGroupAllChecked(group.items, selected)} indeterminate={isGroupIndeterminate(group.items, selected)} onChange={() => groupSelectChanged(group.items)} {onKeydown} tabindex={tabindexFor(groupCkId)} />
231
+ <span id={groupLegendId}><ScreenReaderOnly>Select all </ScreenReaderOnly>{group.name}</span>
232
+ </label>
233
+ {:else}
234
+ <div class="dialog-choices-group-header" id={groupLegendId}>{group.name}</div>
235
+ {/if}
236
+ <div class="dialog-choices-items">
237
+ {#each group.items as item, groupIdx (item.choice.value)}
238
+ {@const checkid = `${fullpath}.${item.originalIndex}`}
239
+ {@const included = value?.includes(item.choice.value)}
240
+ {@const choiceLabel = item.choice.label || (typeof item.choice.value === 'string' ? item.choice.value : '')}
241
+ <label for={checkid} style:width={layout?.width} style:order={layout?.orders[groupIdx]}>
242
+ <Checkbox id={checkid} name={checkid} value={included} descid={getDescribedBy([groupLegendId, descid, messagesid, helptextid, extradescid])} disabled={item.choice.disabled} onChange={() => onChangeCheckbox(setVal, item.choice, included)} {onBlur} {onKeydown} tabindex={tabindexFor(checkid)} />
243
+ <span>{choiceLabel}</span>
244
+ </label>
245
+ {/each}
246
+ </div>
247
+ </div>
248
+ {:else}
249
+ <div class="dialog-choices-items">
250
+ {#each group.items as item, groupIdx (item.choice.value)}
251
+ {@const checkid = `${fullpath}.${item.originalIndex}`}
252
+ {@const included = value?.includes(item.choice.value)}
253
+ {@const choiceLabel = item.choice.label || (typeof item.choice.value === 'string' ? item.choice.value : '')}
254
+ <label for={checkid} style:width={layout?.width} style:order={layout?.orders[groupIdx]}>
255
+ <Checkbox id={checkid} name={checkid} value={included} descid={getDescribedBy([descid, messagesid, helptextid, extradescid])} disabled={item.choice.disabled} onChange={() => onChangeCheckbox(setVal, item.choice, included)} {onBlur} {onKeydown} tabindex={tabindexFor(checkid)} />
256
+ <span>{choiceLabel}</span>
257
+ </label>
258
+ {/each}
259
+ </div>
260
+ {/if}
261
+ {/each}
262
+ {:else}
263
+ {#each choices as choice, idx (choice.value)}
264
+ {@const checkid = `${fullpath}.${idx}`}
265
+ {@const included = value?.includes(choice.value)}
266
+ {@const label = choice.label || (typeof choice.value === 'string' ? choice.value : '')}
267
+ <label for={checkid} style:width style:order={orders[idx]}>
268
+ <Checkbox id={checkid} name={checkid} value={included} descid={getDescribedBy([descid, messagesid, helptextid, extradescid])} disabled={choice.disabled} onChange={() => onChangeCheckbox(setVal, choice, included)} {onBlur} {onKeydown} tabindex={tabindexFor(checkid)} />
269
+ <span>{label}</span>
270
+ </label>
271
+ {/each}
272
+ {/if}
98
273
  </div>
99
274
  </Container>
100
275
  </Field>
@@ -117,4 +292,19 @@
117
292
  label :global(input[type="checkbox"]) {
118
293
  transform: none;
119
294
  }
295
+ .dialog-choices-group {
296
+ padding: 0;
297
+ margin: 0 0 0.3em 0;
298
+ width: 100%;
299
+ }
300
+ .dialog-choices-group-header {
301
+ font-weight: 600;
302
+ margin-bottom: 0.3em;
303
+ }
304
+ .dialog-choices-items {
305
+ display: flex;
306
+ flex-wrap: wrap;
307
+ width: 100%;
308
+ padding-left: 1.5em;
309
+ }
120
310
  </style>
@@ -9,6 +9,7 @@ declare const __propDef: {
9
9
  label?: string;
10
10
  value: any;
11
11
  disabled?: boolean;
12
+ group?: string;
12
13
  }[] | undefined;
13
14
  defaultValue?: any;
14
15
  conditional?: boolean | undefined;
@@ -18,6 +19,7 @@ declare const __propDef: {
18
19
  extradescid?: string | undefined;
19
20
  helptext?: string | undefined;
20
21
  selectAll?: boolean;
22
+ groupSelect?: boolean;
21
23
  };
22
24
  events: {
23
25
  [evt: string]: CustomEvent<any>;
@@ -32,6 +34,11 @@ export type FieldChoicesSlots = typeof __propDef.slots;
32
34
  * choices than the available width of the `<div>` will support then it will create multiple rows to render within using
33
35
  * flex. Ordering is top down by default but can be order horizontally by toggling `leftToRight`.
34
36
  * The value of the field will be an array corresponding to the values of the checkboxes that are checked.
37
+ *
38
+ * Choices may optionally include a `group` property. When any choices have a group, they are rendered in fieldsets
39
+ * with the group string as the legend. Choices without a group render ungrouped. All choices still write to the
40
+ * same field path. `selectAll` adds a global select-all checkbox. `groupSelect` adds per-group select-all
41
+ * checkboxes. The two props are independent and can be used separately or together.
35
42
  */
36
43
  export default class FieldChoices extends SvelteComponentTyped<FieldChoicesProps, FieldChoicesEvents, FieldChoicesSlots> {
37
44
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@dosgato/dialog",
3
3
  "description": "A component library for building forms that edit a JSON document.",
4
- "version": "1.5.9",
4
+ "version": "1.6.0",
5
5
  "scripts": {
6
6
  "prepublishOnly": "npm run package",
7
7
  "dev": "vite dev --force",