@juwel-development/design-system 3.8.0 → 3.9.1

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.
package/README.md CHANGED
@@ -94,6 +94,177 @@ provided by Root, so callers do not compose another empty Option. Derive types w
94
94
  `ComponentProps<typeof Select.Root>` or `ComponentProps<typeof Select.Option>` from React.
95
95
  The consumer still awaits a published library release before changing its dependency.
96
96
 
97
+ ## MultiSelect
98
+
99
+ `MultiSelect` is a compound namespace for selecting zero, one or several options
100
+ independently from a finite set: `MultiSelect.Root` renders a labelled one-line dropdown
101
+ trigger with the selected options as individually removable chips, a count for the chips
102
+ that do not fit, and a clear-all control; `MultiSelect.Option` renders one checkable row in
103
+ the dropdown. The consumer owns the options, the selection, every word and what the selected
104
+ set means; the library owns the control, the selection semantics, focus and the presentation.
105
+
106
+ ```tsx
107
+ import { MultiSelect } from '@juwel-development/design-system';
108
+ import { BehaviorSubject, Subject } from 'rxjs';
109
+
110
+ const topics$ = new BehaviorSubject<readonly string[]>([]);
111
+ const topicChange$ = new Subject<readonly string[]>();
112
+ topicChange$.subscribe((next) => topics$.next(next));
113
+
114
+ <MultiSelect.Root
115
+ label={'Main topics'}
116
+ selected$={topics$}
117
+ onChange$={topicChange$}
118
+ emptyLabel={'No topics selected'}
119
+ removeLabel={'Remove {label}'}
120
+ clearLabel={'Clear topics'}
121
+ overflowLabel={'+{count}'}
122
+ hint={'Songs match any selected topic.'}
123
+ >
124
+ <MultiSelect.Option value={'family'}>{'Family'}</MultiSelect.Option>
125
+ <MultiSelect.Option value={'love'}>{'Love'}</MultiSelect.Option>
126
+ </MultiSelect.Root>;
127
+ ```
128
+
129
+ The same control in German changes only the caller's wording:
130
+
131
+ ```tsx
132
+ <MultiSelect.Root
133
+ label={'Hauptthemen'}
134
+ selected$={topics$}
135
+ onChange$={topicChange$}
136
+ emptyLabel={'Keine Themen ausgewählt'}
137
+ removeLabel={'{label} entfernen'}
138
+ clearLabel={'Themen zurücksetzen'}
139
+ overflowLabel={'{count} weitere'}
140
+ >
141
+ <MultiSelect.Option value={'family'}>{'Familie'}</MultiSelect.Option>
142
+ <MultiSelect.Option value={'love'}>{'Liebe'}</MultiSelect.Option>
143
+ </MultiSelect.Root>;
144
+ ```
145
+
146
+ `Root` requires `label`, `selected$`, `onChange$`, `emptyLabel`, `removeLabel`, `clearLabel`
147
+ and `overflowLabel`; `hint`, `disabled`, `testId` and `children` are optional. Each `Option`
148
+ requires a unique, stable string `value` and a text-only `children` label, and accepts a
149
+ `testId`. Options are direct children of `Root`; arrays and fragments are supported. The
150
+ wording contract is closed: `removeLabel` replaces `{label}` with the option's label to name a
151
+ chip's removal control, and `overflowLabel` replaces `{count}` with the number of selected
152
+ options hidden behind the count. There are no callback props and no built-in English; a
153
+ missing `hint` renders nothing.
154
+
155
+ **Streams.** `selected$` is a read-only `Observable<readonly string[]>` of the current
156
+ selection. Root renders the latest emission and nothing else: empty before the first
157
+ emission, empty again while a replaced source has not yet emitted, and updated silently on
158
+ every emission whether the dropdown is open, closed or disabled. Use a replaying source such
159
+ as a `BehaviorSubject` or `ReplaySubject(1)` so a remounted control shows the current state
160
+ at once. `onChange$` is a `Subject<readonly string[]>` that receives one fresh full proposed
161
+ selection per user edit - a toggle, a chip removal or clear-all - ordered by option order,
162
+ without duplicates and holding only supplied identities. Handed-in arrays are never mutated.
163
+ Rendering, opening, closing, option updates, source replacement and `selected$` emissions
164
+ never emit. Feed accepted proposals back into `selected$` immediately for ordinary
165
+ interaction; an unanswered proposal leaves the selection unchanged, and repeating the edit
166
+ repeats the proposal. Root subscribes only to `selected$`, unsubscribes on replacement and
167
+ unmount, and never completes either stream or assigns behaviour to their errors or completion.
168
+
169
+ **Caller obligations.** Keep option identities stable across reordering and translation, so
170
+ selection is preserved by identity; map options with the value as the React key. `selected$`
171
+ names supplied identities only: when an update removes options, remove their identities from
172
+ the selection in the same logical update. MultiSelect prunes nothing, invents nothing and
173
+ emits no synthetic change to reconcile invalid input.
174
+
175
+ **Behaviour.** The closed control stays on one line at every width: the leading chips that
176
+ fit are shown in option order, the rest are counted by `overflowLabel`, and at narrow widths
177
+ only the count remains. Overflow is recalculated on width, label and selection changes
178
+ without touching the selection; activating the count opens the dropdown, so every selection
179
+ stays reachable. Long chip labels truncate visually while the removal control keeps the full
180
+ name. The dropdown floats over the page on the shared `--elevation-floating` role, opens above
181
+ the control when the viewport below cannot hold it, is capped to the room it has and scrolls
182
+ its options. `disabled` keeps the selection visible, closes an open dropdown, disables every
183
+ control and emits nothing. With no options the dropdown opens empty.
184
+
185
+ **Keyboard and accessibility.** The visible label names the trigger and the option group; the
186
+ trigger exposes `aria-expanded` and, while open, `aria-controls`. Each option is a
187
+ `role="checkbox"` button with `aria-checked` and a tick that does not depend on colour. Enter,
188
+ Space or ArrowDown on the closed trigger opens the dropdown and focuses the first selected
189
+ option, or the first option; with no options focus stays on the trigger. Tab and Shift+Tab
190
+ traverse chips, clear-all and options normally with no focus trap; Space toggles a focused
191
+ option. Escape closes and returns focus to the trigger. Focus leaving the whole control, an
192
+ outside pointer interaction and the trigger itself close the dropdown without moving focus or
193
+ emitting. Chip removals and clear-all are named, non-submitting buttons outside the trigger:
194
+ a removal that takes its own focused control away moves focus to the next visible removal,
195
+ then the preceding one, then the trigger; clear-all returns focus to the trigger; a chip hidden
196
+ by overflow while focused hands focus to the trigger. This is a consumer-controlled selection
197
+ control, not a form field: it has no `name`, native submission, reset or validation.
198
+
199
+ ## NumberInput
200
+
201
+ `NumberInput` is a labelled field for typed amounts and thresholds. It renders a text control
202
+ with a decimal keyboard hint (`inputmode="decimal"`) and keeps the entered text exactly as typed:
203
+ blank, `0`, `-`, `1.`, `1,5`, `1.5` and pasted content such as `12abc` stay distinct, untrimmed,
204
+ untruncated and unconverted. It is a separate roster entry with Input's field anatomy, not an
205
+ Input variant, and it does not extend Input's contract.
206
+
207
+ ```tsx
208
+ import { NumberInput } from '@juwel-development/design-system';
209
+ import { Subject } from 'rxjs';
210
+
211
+ const maxPriceInput$ = new Subject<string>();
212
+ const maxPriceReset$ = new Subject<void>();
213
+
214
+ <NumberInput
215
+ label={'Maximum price'}
216
+ name={'maxPrice'}
217
+ placeholder={'No limit'}
218
+ hint={'Leave blank for no limit'}
219
+ defaultValue={savedMaxPrice}
220
+ invalid={maxPriceReading.kind === 'rejected'}
221
+ errorMessage={'Enter an amount such as 12.50'}
222
+ onInput$={maxPriceInput$}
223
+ reset$={maxPriceReset$}
224
+ />;
225
+ ```
226
+
227
+ `label` and `name` are required. Optional props are `required`, `optionalLabel`, `hint`,
228
+ `invalid`, `errorMessage`, `disabled`, `placeholder`, `defaultValue`, `onInput$`, `reset$` and
229
+ `testId`, with Input's meaning. Derive the props type with `ComponentProps<typeof NumberInput>`.
230
+ There are no `min`, `max`, `step`, `pattern` or length props and no steppers, formatting or key
231
+ filtering ([ADR 0009](docs/adr/0009-content-rules-stay-with-the-consumer.md)); the control has
232
+ textbox semantics, not spinbutton semantics.
233
+
234
+ **The consumer owns interpretation.** `onInput$` emits the current text as a string on every user
235
+ edit - typing, pasting and clearing by editing - and never a parsed number, `NaN` or `Infinity`.
236
+ The consumer decides whether the text is blank, unfinished, invalid or an accepted finite number,
237
+ which decimal and grouping conventions it accepts, and when to show an error; it drives `invalid`
238
+ and `errorMessage` in its own language. Validate the whole string before converting (a full-match
239
+ pattern, then `Number`), check the result with `Number.isFinite`, and never accept a numeric prefix
240
+ of invalid text. Blank is not zero: the field never converts one into the other. Integer-only and
241
+ whole-currency rules are consumer rules. The `EnglishParsing` and `GermanParsing` stories show one
242
+ such loop each; the library publishes no parser or locale service.
243
+
244
+ **Saved state and clearing.** `defaultValue` initialises the field on mount only; a later change
245
+ does not replace the current edit, so a mounted field keeps what the user typed across ordinary
246
+ rerenders. To restore saved text - returning to a tab, loading another record - remount the field
247
+ with the saved text as `defaultValue`. `reset$` empties the live node in place, so focus survives,
248
+ and emits nothing on `onInput$`; clear your own saved state alongside it if the field must stay
249
+ empty after a remount. Native form reset restores the form default (`defaultValue`), also silently.
250
+ Neither operation updates consumer-owned state. A disabled field accepts no edits and emits nothing,
251
+ but a `reset$` emission still clears it. Rendering, message changes and remount initialisation emit
252
+ no input events.
253
+
254
+ **Streams.** The component subscribes only to `reset$` and unsubscribes when the Subject is replaced
255
+ or the field unmounts. It only calls `.next()` on `onInput$` and never subscribes to, completes or
256
+ errors either stream; the consumer owns both lifetimes. There is no controlled `value` prop, no
257
+ live value stream and no React callback prop.
258
+
259
+ **Forms and accessibility.** The control works without JavaScript: the form submits the text by
260
+ `name`, and `required` checks presence only, so `1,5` and `twelve` both submit. Numeric validity is
261
+ not guaranteed by the form - a server must parse and validate on a no-JavaScript round-trip and
262
+ re-render the field `invalid` with its own error text, and a JavaScript consumer must itself prevent
263
+ an invalid submission. The label names the control, hint and error describe it, ids are unique per
264
+ instance, and the shared focus ring and field presentation apply in both themes. The device chooses
265
+ the actual keyboard: a decimal separator and digits are requested, but a particular layout, a minus
266
+ key or the exclusion of other characters is not promised.
267
+
97
268
  ## Collection
98
269
 
99
270
  `Collection` is a vertical group of freely composed items with internal hairlines and