sheer-ui 0.2.0 → 0.3.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sheer-ui",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Svelte 5 UI component library with Tailwind CSS",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -2,7 +2,7 @@
2
2
  import { boxWith } from '../../../internal/tools/index.js';
3
3
  import { mergeProps } from '../../../internal/merge-props.js';
4
4
  import type { ToggleGroupItemProps } from '../types.js';
5
- import { ToggleGroupItemState } from '../toggle-group.svelte.js';
5
+ import { createToggleGroupItem } from '../toggle-group.svelte.js';
6
6
  import { createId } from '../../../internal/create-id.js';
7
7
  import { getToggleGroupCtx } from './toggle-group.svelte';
8
8
  import { toggleVariants, type ToggleSize, type ToggleVariant } from '../../toggle/variants.js';
@@ -27,7 +27,7 @@
27
27
 
28
28
  const ctx = getToggleGroupCtx();
29
29
 
30
- const itemState = ToggleGroupItemState.create({
30
+ const itemState = createToggleGroupItem({
31
31
  id: boxWith(() => id),
32
32
  value: boxWith(() => value),
33
33
  disabled: boxWith(() => disabled ?? false),
@@ -14,7 +14,8 @@
14
14
 
15
15
  <script lang="ts">
16
16
  import { untrack } from 'svelte';
17
- import { type WritableBox, boxWith } from '../../../internal/tools/index.js';
17
+ import { boxWith, repairBindable } from '../../../internal/tools/index.js';
18
+ import { emptySelection } from '../../../internal/selection.svelte.js';
18
19
  import { mergeProps } from '../../../internal/merge-props.js';
19
20
  import type { ToggleGroupRootProps } from '../types.js';
20
21
  import { ToggleGroupRootState } from '../toggle-group.svelte.js';
@@ -43,35 +44,27 @@
43
44
  // Context for toggle group items (values are stable, no reactivity needed)
44
45
  setToggleGroupCtx(untrack(() => ({ variant, size, spacing })));
45
46
 
46
- // Mode is construction-static: ToggleGroupRootState chooses a single/multiple class once.
47
+ // Mode is construction-static: the selection stays single or multiple for the group's life.
47
48
  const valueType = untrack(() => type);
48
49
 
49
- function getDefaultValue(): string | string[] {
50
- return valueType === 'single' ? '' : [];
51
- }
52
-
53
- function handleDefaultValue() {
54
- if (value !== undefined) return;
55
- value = getDefaultValue();
56
- }
57
-
58
- function getValue() {
59
- return value ?? getDefaultValue();
60
- }
61
-
62
- // SSR
63
- handleDefaultValue();
50
+ // The group owns a mode-specific controlled value.
51
+ repairBindable(
52
+ () => value,
53
+ () => {
54
+ if (value === undefined) value = emptySelection(valueType);
55
+ },
56
+ );
64
57
 
65
58
  const rootState = ToggleGroupRootState.create({
66
59
  id: boxWith(() => id),
67
60
  value: boxWith(
68
- () => getValue(),
61
+ () => value ?? emptySelection(valueType),
69
62
  (v) => {
70
63
  value = v;
71
- // @ts-expect-error - we know
72
- onValueChange(v);
64
+ // oxlint-disable-next-line no-explicit-any
65
+ onValueChange(v as any);
73
66
  },
74
- ) as WritableBox<string> | WritableBox<string[]>,
67
+ ),
75
68
  disabled: boxWith(() => disabled),
76
69
  loop: boxWith(() => loop),
77
70
  orientation: boxWith(() => orientation),
@@ -1,20 +1,25 @@
1
1
  import { createContext } from 'svelte';
2
- import { type WritableBox, type ReadableBoxedValues, type WritableBoxedValues, attachRef } from '../../internal/tools/index.js';
3
- import { createBitsAttrs, getAriaChecked, boolToStr, boolToEmptyStrOrUndef, boolToTrueOrUndef } from '../../internal/attrs.js';
4
- import { kbd } from '../../internal/kbd.js';
2
+ import { type ReadableBox, type ReadableBoxedValues, type WritableBox, attachRef } from '../../internal/tools/index.js';
3
+ import { createBitsAttrs, boolToEmptyStrOrUndef } from '../../internal/attrs.js';
5
4
  import type { Orientation } from '../../internal/index.js';
6
- import type { BitsKeyboardEvent, BitsMouseEvent, RefAttachment, WithRefOpts } from '../../internal/types.js';
5
+ import type { RefAttachment, WithRefOpts } from '../../internal/types.js';
7
6
  import { RovingFocusGroup } from '../../internal/roving-focus-group.js';
8
- import { RovingFocusItem } from '../../internal/roving-focus-item.svelte.js';
7
+ import {
8
+ type SelectionGroup,
9
+ type SelectionItemOpts,
10
+ type SelectionType,
11
+ SelectionItemState,
12
+ SelectionValue,
13
+ } from '../../internal/selection.svelte.js';
9
14
 
10
15
  export const toggleGroupAttrs = createBitsAttrs({
11
16
  component: 'toggle-group',
12
17
  parts: ['root', 'item'],
13
18
  });
14
19
 
15
- const [getToggleGroupRoot, setToggleGroupRoot] = createContext<ToggleGroup>();
20
+ const [getToggleGroupRoot, setToggleGroupRoot] = createContext<ToggleGroupRootState>();
16
21
 
17
- interface ToggleGroupBaseStateOpts
22
+ interface ToggleGroupRootStateOpts
18
23
  extends
19
24
  WithRefOpts,
20
25
  ReadableBoxedValues<{
@@ -22,16 +27,31 @@ interface ToggleGroupBaseStateOpts
22
27
  rovingFocus: boolean;
23
28
  loop: boolean;
24
29
  orientation: Orientation;
25
- }> {}
30
+ }> {
31
+ type: SelectionType;
32
+ value: WritableBox<string | string[]>;
33
+ }
26
34
 
27
- abstract class ToggleGroupBaseState {
28
- readonly opts: ToggleGroupBaseStateOpts;
35
+ export class ToggleGroupRootState implements SelectionGroup {
36
+ static create(opts: ToggleGroupRootStateOpts) {
37
+ return setToggleGroupRoot(new ToggleGroupRootState(opts));
38
+ }
39
+ readonly opts: ToggleGroupRootStateOpts;
40
+ readonly selection: SelectionValue;
41
+ readonly disabled: ReadableBox<boolean>;
42
+ readonly orientation: ReadableBox<Orientation>;
43
+ readonly rovingFocus: ReadableBox<boolean>;
29
44
  readonly rovingFocusGroup: RovingFocusGroup;
45
+ readonly selectionTakesTabStop = true;
30
46
  readonly attachment: RefAttachment;
31
47
 
32
- constructor(opts: ToggleGroupBaseStateOpts) {
48
+ constructor(opts: ToggleGroupRootStateOpts) {
33
49
  this.opts = opts;
34
- this.attachment = attachRef(this.opts.ref);
50
+ this.selection = new SelectionValue(opts.type, opts.value);
51
+ this.disabled = opts.disabled;
52
+ this.orientation = opts.orientation;
53
+ this.rovingFocus = opts.rovingFocus;
54
+ this.attachment = attachRef(opts.ref);
35
55
  this.rovingFocusGroup = new RovingFocusGroup({
36
56
  candidateAttr: toggleGroupAttrs.item,
37
57
  rootNode: opts.ref,
@@ -53,183 +73,6 @@ abstract class ToggleGroupBaseState {
53
73
  );
54
74
  }
55
75
 
56
- interface ToggleGroupSingleStateOpts
57
- extends
58
- ToggleGroupBaseStateOpts,
59
- WritableBoxedValues<{
60
- value: string;
61
- }> {}
62
-
63
- class ToggleGroupSingleState extends ToggleGroupBaseState {
64
- override readonly opts: ToggleGroupSingleStateOpts;
65
- isMulti = false;
66
- readonly anyPressed = $derived.by(() => this.opts.value.current !== '');
67
-
68
- constructor(opts: ToggleGroupSingleStateOpts) {
69
- super(opts);
70
- this.opts = opts;
71
- }
72
-
73
- includesItem(item: string) {
74
- return this.opts.value.current === item;
75
- }
76
-
77
- toggleItem(item: string, id: string) {
78
- if (this.includesItem(item)) {
79
- this.opts.value.current = '';
80
- } else {
81
- this.opts.value.current = item;
82
- this.rovingFocusGroup.setCurrentTabStopId(id);
83
- }
84
- }
85
- }
86
-
87
- //
88
- // MULTIPLE
89
- //
90
-
91
- interface ToggleGroupMultipleStateOpts
92
- extends
93
- ToggleGroupBaseStateOpts,
94
- WritableBoxedValues<{
95
- value: string[];
96
- }> {}
97
-
98
- class ToggleGroupMultipleState extends ToggleGroupBaseState {
99
- override readonly opts: ToggleGroupMultipleStateOpts;
100
- isMulti = true;
101
- readonly anyPressed = $derived.by(() => this.opts.value.current.length > 0);
102
-
103
- constructor(opts: ToggleGroupMultipleStateOpts) {
104
- super(opts);
105
-
106
- this.opts = opts;
107
- }
108
-
109
- includesItem(item: string) {
110
- return this.opts.value.current.includes(item);
111
- }
112
-
113
- toggleItem(item: string, id: string) {
114
- if (this.includesItem(item)) {
115
- this.opts.value.current = this.opts.value.current.filter((v) => v !== item);
116
- } else {
117
- this.opts.value.current = [...this.opts.value.current, item];
118
- this.rovingFocusGroup.setCurrentTabStopId(id);
119
- }
120
- }
121
- }
122
-
123
- type ToggleGroup = ToggleGroupSingleState | ToggleGroupMultipleState;
124
-
125
- interface ToggleGroupRootOpts
126
- extends
127
- WithRefOpts,
128
- ReadableBoxedValues<{
129
- disabled: boolean;
130
- rovingFocus: boolean;
131
- loop: boolean;
132
- orientation: Orientation;
133
- }> {
134
- type: 'single' | 'multiple';
135
- value: WritableBox<string> | WritableBox<string[]>;
136
- }
137
-
138
- export class ToggleGroupRootState {
139
- static create(opts: ToggleGroupRootOpts): ToggleGroup {
140
- const { type, ...rest } = opts;
141
- const rootState =
142
- type === 'single'
143
- ? new ToggleGroupSingleState(rest as ToggleGroupSingleStateOpts)
144
- : new ToggleGroupMultipleState(rest as ToggleGroupMultipleStateOpts);
145
- return setToggleGroupRoot(rootState);
146
- }
76
+ export function createToggleGroupItem(opts: SelectionItemOpts) {
77
+ return new SelectionItemState(opts, getToggleGroupRoot());
147
78
  }
148
-
149
- interface ToggleGroupItemStateOpts
150
- extends
151
- WithRefOpts,
152
- ReadableBoxedValues<{
153
- value: string;
154
- disabled: boolean;
155
- }> {}
156
-
157
- export class ToggleGroupItemState {
158
- static create(opts: ToggleGroupItemStateOpts) {
159
- return new ToggleGroupItemState(opts, getToggleGroupRoot());
160
- }
161
- readonly opts: ToggleGroupItemStateOpts;
162
- readonly root: ToggleGroup;
163
- readonly rovingItem: RovingFocusItem;
164
- readonly #isDisabled = $derived.by(() => this.opts.disabled.current || this.root.opts.disabled.current);
165
- readonly isPressed = $derived.by(() => this.root.includesItem(this.opts.value.current));
166
-
167
- readonly #ariaChecked = $derived.by(() => {
168
- return this.root.isMulti ? undefined : getAriaChecked(this.isPressed, false);
169
- });
170
-
171
- readonly #ariaPressed = $derived.by(() => {
172
- return this.root.isMulti ? boolToStr(this.isPressed) : undefined;
173
- });
174
-
175
- constructor(opts: ToggleGroupItemStateOpts, root: ToggleGroup) {
176
- this.opts = opts;
177
- this.root = root;
178
- this.rovingItem = new RovingFocusItem({
179
- group: this.root.rovingFocusGroup,
180
- ref: this.opts.ref,
181
- enabled: this.root.opts.rovingFocus,
182
- });
183
-
184
- this.onclick = this.onclick.bind(this);
185
- this.onkeydown = this.onkeydown.bind(this);
186
- }
187
-
188
- #toggleItem() {
189
- if (this.#isDisabled) return;
190
- this.root.toggleItem(this.opts.value.current, this.opts.id.current);
191
- }
192
-
193
- onclick(_: BitsMouseEvent) {
194
- if (this.#isDisabled) return;
195
- this.root.toggleItem(this.opts.value.current, this.opts.id.current);
196
- }
197
-
198
- onkeydown(e: BitsKeyboardEvent) {
199
- if (this.#isDisabled) return;
200
- if (e.key === kbd.ENTER || e.key === kbd.SPACE) {
201
- e.preventDefault();
202
- this.#toggleItem();
203
- return;
204
- }
205
- if (!this.root.opts.rovingFocus.current) return;
206
-
207
- this.rovingItem.handleKeydown(e);
208
- }
209
-
210
- readonly snippetProps = $derived.by(() => ({
211
- pressed: this.isPressed,
212
- }));
213
-
214
- readonly props = $derived.by(
215
- () =>
216
- ({
217
- id: this.opts.id.current,
218
- role: this.root.isMulti ? undefined : 'radio',
219
- 'data-orientation': this.root.opts.orientation.current,
220
- 'data-disabled': boolToEmptyStrOrUndef(this.#isDisabled),
221
- 'data-state': getToggleItemDataState(this.isPressed),
222
- 'data-value': this.opts.value.current,
223
- 'aria-pressed': this.#ariaPressed,
224
- 'aria-checked': this.#ariaChecked,
225
- disabled: boolToTrueOrUndef(this.#isDisabled),
226
- [toggleGroupAttrs.item]: '',
227
- //
228
- onclick: this.onclick,
229
- onkeydown: this.onkeydown,
230
- ...this.rovingItem.props,
231
- }) as const,
232
- );
233
- }
234
-
235
- const getToggleItemDataState = (condition: boolean) => condition ? 'on' : 'off';
@@ -2,7 +2,7 @@
2
2
  import { boxWith } from '../../../internal/tools/index.js';
3
3
  import { mergeProps } from '../../../internal/merge-props.js';
4
4
  import type { ToolbarGroupItemProps } from '../types.js';
5
- import { ToolbarGroupItemState } from '../toolbar.svelte.js';
5
+ import { createToolbarGroupItem } from '../toolbar.svelte.js';
6
6
  import { createId } from '../../../internal/create-id.js';
7
7
  import { toggleVariants, type ToggleSize, type ToggleVariant } from '../../toggle/variants.js';
8
8
 
@@ -24,7 +24,7 @@
24
24
  size?: ToggleSize;
25
25
  } = $props();
26
26
 
27
- const groupItemState = ToolbarGroupItemState.create({
27
+ const groupItemState = createToolbarGroupItem({
28
28
  id: boxWith(() => id),
29
29
  value: boxWith(() => value),
30
30
  disabled: boxWith(() => disabled ?? false),
@@ -48,9 +48,9 @@
48
48
  </script>
49
49
 
50
50
  {#if child}
51
- {@render child({ props: mergedProps, pressed: groupItemState.isPressed })}
51
+ {@render child({ props: mergedProps, ...groupItemState.snippetProps })}
52
52
  {:else}
53
53
  <button {...mergedProps}>
54
- {@render children?.({ pressed: groupItemState.isPressed })}
54
+ {@render children?.(groupItemState.snippetProps)}
55
55
  </button>
56
56
  {/if}
@@ -1,6 +1,7 @@
1
1
  <script lang="ts">
2
2
  import { untrack } from 'svelte';
3
- import { type WritableBox, boxWith } from '../../../internal/tools/index.js';
3
+ import { boxWith, repairBindable } from '../../../internal/tools/index.js';
4
+ import { emptySelection } from '../../../internal/selection.svelte.js';
4
5
  import { mergeProps } from '../../../internal/merge-props.js';
5
6
  import type { ToolbarGroupProps } from '../types.js';
6
7
  import { ToolbarGroupState } from '../toolbar.svelte.js';
@@ -20,37 +21,29 @@
20
21
  ...restProps
21
22
  }: ToolbarGroupProps = $props();
22
23
 
23
- // Mode is construction-static: ToolbarGroupState chooses a single/multiple class once.
24
+ // Mode is construction-static: the selection stays single or multiple for the group's life.
24
25
  const valueType = untrack(() => type);
25
26
 
26
- function getDefaultValue(): string | string[] {
27
- return valueType === 'single' ? '' : [];
28
- }
29
-
30
- function handleDefaultValue() {
31
- if (value !== undefined) return;
32
- value = getDefaultValue();
33
- }
34
-
35
- function getValue() {
36
- return value ?? getDefaultValue();
37
- }
38
-
39
- // SSR
40
- handleDefaultValue();
27
+ // The group owns a mode-specific controlled value.
28
+ repairBindable(
29
+ () => value,
30
+ () => {
31
+ if (value === undefined) value = emptySelection(valueType);
32
+ },
33
+ );
41
34
 
42
35
  const groupState = ToolbarGroupState.create({
43
36
  id: boxWith(() => id),
44
37
  disabled: boxWith(() => disabled),
45
38
  type: valueType,
46
39
  value: boxWith(
47
- () => getValue(),
40
+ () => value ?? emptySelection(valueType),
48
41
  (v) => {
49
42
  value = v;
50
- // @ts-expect-error - we know
51
- onValueChange(v);
43
+ // oxlint-disable-next-line no-explicit-any
44
+ onValueChange(v as any);
52
45
  },
53
- ) as WritableBox<string> | WritableBox<string[]>,
46
+ ),
54
47
  ref: boxWith(
55
48
  () => ref,
56
49
  (v) => (ref = v),
@@ -1,11 +1,17 @@
1
1
  import { createContext } from 'svelte';
2
- import { type WritableBox, type WritableBoxedValues, type ReadableBoxedValues, attachRef } from '../../internal/tools/index.js';
3
- import { createBitsAttrs, getAriaChecked, boolToStr, boolToEmptyStrOrUndef, boolToTrueOrUndef } from '../../internal/attrs.js';
4
- import { kbd } from '../../internal/kbd.js';
2
+ import { type ReadableBox, type ReadableBoxedValues, type WritableBox, attachRef } from '../../internal/tools/index.js';
3
+ import { createBitsAttrs, boolToEmptyStrOrUndef, boolToTrueOrUndef } from '../../internal/attrs.js';
5
4
  import type { Orientation } from '../../internal/index.js';
6
- import type { BitsKeyboardEvent, BitsMouseEvent, RefAttachment, WithRefOpts } from '../../internal/types.js';
5
+ import type { BitsKeyboardEvent, RefAttachment, WithRefOpts } from '../../internal/types.js';
7
6
  import { RovingFocusGroup } from '../../internal/roving-focus-group.js';
8
7
  import { RovingFocusItem } from '../../internal/roving-focus-item.svelte.js';
8
+ import {
9
+ type SelectionGroup,
10
+ type SelectionItemOpts,
11
+ type SelectionType,
12
+ SelectionItemState,
13
+ SelectionValue,
14
+ } from '../../internal/selection.svelte.js';
9
15
 
10
16
  export const toolbarAttrs = createBitsAttrs({
11
17
  component: 'toolbar',
@@ -13,7 +19,7 @@ export const toolbarAttrs = createBitsAttrs({
13
19
  });
14
20
 
15
21
  const [getToolbarRoot, setToolbarRoot] = createContext<ToolbarRootState>();
16
- const [getToolbarGroup, setToolbarGroup] = createContext<ToolbarGroup>();
22
+ const [getToolbarGroup, setToolbarGroup] = createContext<ToolbarGroupState>();
17
23
  interface ToolbarRootStateOpts
18
24
  extends
19
25
  WithRefOpts,
@@ -54,21 +60,38 @@ export class ToolbarRootState {
54
60
  );
55
61
  }
56
62
 
57
- interface ToolbarGroupBaseStateOpts
63
+ interface ToolbarGroupStateOpts
58
64
  extends
59
65
  WithRefOpts,
60
66
  ReadableBoxedValues<{
61
67
  disabled: boolean;
62
- }> {}
68
+ }> {
69
+ type: SelectionType;
70
+ value: WritableBox<string | string[]>;
71
+ }
63
72
 
64
- abstract class ToolbarGroupBaseState {
65
- readonly opts: ToolbarGroupBaseStateOpts;
73
+ /** A selection group inside the toolbar: its items rove with the toolbar's other items. */
74
+ export class ToolbarGroupState implements SelectionGroup {
75
+ static create(opts: ToolbarGroupStateOpts) {
76
+ return setToolbarGroup(new ToolbarGroupState(opts, getToolbarRoot()));
77
+ }
78
+ readonly opts: ToolbarGroupStateOpts;
66
79
  readonly root: ToolbarRootState;
80
+ readonly selection: SelectionValue;
81
+ readonly disabled: ReadableBox<boolean>;
82
+ readonly orientation: ReadableBox<Orientation>;
83
+ readonly rovingFocusGroup: RovingFocusGroup;
84
+ readonly selectionTakesTabStop = false;
85
+ readonly extraItemAttrs = { [toolbarAttrs['group-item']]: '' } as const;
67
86
  readonly attachment: RefAttachment;
68
87
 
69
- constructor(opts: ToolbarGroupBaseStateOpts, root: ToolbarRootState) {
88
+ constructor(opts: ToolbarGroupStateOpts, root: ToolbarRootState) {
70
89
  this.opts = opts;
71
90
  this.root = root;
91
+ this.selection = new SelectionValue(opts.type, opts.value);
92
+ this.disabled = opts.disabled;
93
+ this.orientation = root.opts.orientation;
94
+ this.rovingFocusGroup = root.rovingFocusGroup;
72
95
  this.attachment = attachRef(this.opts.ref);
73
96
  }
74
97
 
@@ -85,184 +108,8 @@ abstract class ToolbarGroupBaseState {
85
108
  );
86
109
  }
87
110
 
88
- interface ToolbarGroupSingleStateOpts
89
- extends
90
- ToolbarGroupBaseStateOpts,
91
- WritableBoxedValues<{
92
- value: string;
93
- }> {}
94
-
95
- class ToolbarGroupSingleState extends ToolbarGroupBaseState {
96
- override readonly opts: ToolbarGroupSingleStateOpts;
97
- override readonly root: ToolbarRootState;
98
- readonly isMulti = false as const;
99
- readonly anyPressed = $derived.by(() => this.opts.value.current !== '');
100
-
101
- constructor(opts: ToolbarGroupSingleStateOpts, root: ToolbarRootState) {
102
- super(opts, root);
103
-
104
- this.opts = opts;
105
- this.root = root;
106
- }
107
-
108
- includesItem(item: string) {
109
- return this.opts.value.current === item;
110
- }
111
-
112
- toggleItem(item: string) {
113
- if (this.includesItem(item)) {
114
- this.opts.value.current = '';
115
- } else {
116
- this.opts.value.current = item;
117
- }
118
- }
119
- }
120
-
121
- interface ToolbarGroupMultipleStateOpts
122
- extends
123
- ToolbarGroupBaseStateOpts,
124
- WritableBoxedValues<{
125
- value: string[];
126
- }> {}
127
-
128
- class ToolbarGroupMultipleState extends ToolbarGroupBaseState {
129
- override readonly opts: ToolbarGroupMultipleStateOpts;
130
- override readonly root: ToolbarRootState;
131
- readonly isMulti = true as const;
132
- readonly anyPressed = $derived.by(() => this.opts.value.current.length > 0);
133
-
134
- constructor(opts: ToolbarGroupMultipleStateOpts, root: ToolbarRootState) {
135
- super(opts, root);
136
-
137
- this.opts = opts;
138
- this.root = root;
139
- }
140
-
141
- includesItem(item: string) {
142
- return this.opts.value.current.includes(item);
143
- }
144
-
145
- toggleItem(item: string) {
146
- if (this.includesItem(item)) {
147
- this.opts.value.current = this.opts.value.current.filter((v) => v !== item);
148
- } else {
149
- this.opts.value.current = [...this.opts.value.current, item];
150
- }
151
- }
152
- }
153
-
154
- type ToolbarGroup = ToolbarGroupSingleState | ToolbarGroupMultipleState;
155
-
156
- interface ToolbarGroupRootOpts
157
- extends
158
- WithRefOpts,
159
- ReadableBoxedValues<{
160
- disabled: boolean;
161
- }> {
162
- type: 'single' | 'multiple';
163
- value: WritableBox<string> | WritableBox<string[]>;
164
- }
165
-
166
- export class ToolbarGroupState {
167
- static create(opts: ToolbarGroupRootOpts): ToolbarGroup {
168
- const { type, ...rest } = opts;
169
- const rootState = getToolbarRoot();
170
- const groupState =
171
- type === 'single'
172
- ? new ToolbarGroupSingleState(rest as ToolbarGroupSingleStateOpts, rootState)
173
- : new ToolbarGroupMultipleState(rest as ToolbarGroupMultipleStateOpts, rootState);
174
-
175
- return setToolbarGroup(groupState);
176
- }
177
- }
178
-
179
- //
180
- // ITEM
181
- //
182
-
183
- interface ToolbarGroupItemStateOpts
184
- extends
185
- WithRefOpts,
186
- ReadableBoxedValues<{
187
- value: string;
188
- disabled: boolean;
189
- }> {}
190
-
191
- export class ToolbarGroupItemState {
192
- static create(opts: ToolbarGroupItemStateOpts) {
193
- const group = getToolbarGroup();
194
- return new ToolbarGroupItemState(opts, group, group.root);
195
- }
196
- readonly opts: ToolbarGroupItemStateOpts;
197
- readonly group: ToolbarGroup;
198
- readonly root: ToolbarRootState;
199
- readonly rovingItem: RovingFocusItem;
200
- readonly #isDisabled = $derived.by(() => this.opts.disabled.current || this.group.opts.disabled.current);
201
-
202
- constructor(opts: ToolbarGroupItemStateOpts, group: ToolbarGroup, root: ToolbarRootState) {
203
- this.opts = opts;
204
- this.group = group;
205
- this.root = root;
206
- this.rovingItem = new RovingFocusItem({
207
- group: this.root.rovingFocusGroup,
208
- ref: this.opts.ref,
209
- });
210
-
211
- this.onclick = this.onclick.bind(this);
212
- this.onkeydown = this.onkeydown.bind(this);
213
- }
214
-
215
- #toggleItem() {
216
- if (this.#isDisabled) return;
217
- this.group.toggleItem(this.opts.value.current);
218
- }
219
-
220
- onclick(_: BitsMouseEvent) {
221
- if (this.#isDisabled) return;
222
- this.#toggleItem();
223
- }
224
-
225
- onkeydown(e: BitsKeyboardEvent) {
226
- if (this.#isDisabled) return;
227
- if (e.key === kbd.ENTER || e.key === kbd.SPACE) {
228
- e.preventDefault();
229
- this.#toggleItem();
230
- return;
231
- }
232
-
233
- this.rovingItem.handleKeydown(e);
234
- }
235
-
236
- readonly isPressed = $derived.by(() => this.group.includesItem(this.opts.value.current));
237
-
238
- readonly #ariaChecked = $derived.by(() => {
239
- return this.group.isMulti ? undefined : getAriaChecked(this.isPressed, false);
240
- });
241
-
242
- readonly #ariaPressed = $derived.by(() => {
243
- return this.group.isMulti ? boolToStr(this.isPressed) : undefined;
244
- });
245
-
246
- readonly props = $derived.by(
247
- () =>
248
- ({
249
- id: this.opts.id.current,
250
- role: this.group.isMulti ? undefined : 'radio',
251
- 'data-orientation': this.root.opts.orientation.current,
252
- 'data-disabled': boolToEmptyStrOrUndef(this.#isDisabled),
253
- 'data-state': getToggleItemDataState(this.isPressed),
254
- 'data-value': this.opts.value.current,
255
- 'aria-pressed': this.#ariaPressed,
256
- 'aria-checked': this.#ariaChecked,
257
- [toolbarAttrs.item]: '',
258
- [toolbarAttrs['group-item']]: '',
259
- disabled: boolToTrueOrUndef(this.#isDisabled),
260
- //
261
- onclick: this.onclick,
262
- onkeydown: this.onkeydown,
263
- ...this.rovingItem.props,
264
- }) as const,
265
- );
111
+ export function createToolbarGroupItem(opts: SelectionItemOpts) {
112
+ return new SelectionItemState(opts, getToolbarGroup());
266
113
  }
267
114
 
268
115
  interface ToolbarLinkStateOpts extends WithRefOpts {}
@@ -365,9 +212,3 @@ export class ToolbarButtonState {
365
212
  }) as const,
366
213
  );
367
214
  }
368
-
369
- //
370
- // HELPERS
371
- //
372
-
373
- const getToggleItemDataState = (condition: boolean) => condition ? 'on' : 'off';
@@ -55,9 +55,12 @@ type RovingFocusGroupOptions = (
55
55
  export class RovingFocusGroup {
56
56
  readonly #opts: RovingFocusGroupOptions;
57
57
  readonly #currentTabStopId = simpleBox<string | null>(null);
58
+ /** What a candidate of an attribute-marked group stamps on itself; empty for the other kinds. */
59
+ readonly candidateAttrs: Record<string, ''>;
58
60
 
59
61
  constructor(opts: RovingFocusGroupOptions) {
60
62
  this.#opts = opts;
63
+ this.candidateAttrs = opts.candidateAttr ? { [opts.candidateAttr]: '' } : {};
61
64
  }
62
65
 
63
66
  getCandidateNodes() {
@@ -0,0 +1,129 @@
1
+ import type { ReadableBox, ReadableBoxedValues, WritableBox } from './tools/index.js';
2
+ import { boolToStr, boolToEmptyStrOrUndef, boolToTrueOrUndef, getAriaChecked } from './attrs.js';
3
+ import { kbd } from './kbd.js';
4
+ import type { Orientation } from './index.js';
5
+ import type { BitsKeyboardEvent, BitsMouseEvent, WithRefOpts } from './types.js';
6
+ import type { RovingFocusGroup } from './roving-focus-group.js';
7
+ import { RovingFocusItem } from './roving-focus-item.svelte.js';
8
+
9
+ export type SelectionType = 'single' | 'multiple';
10
+
11
+ export const emptySelection = (type: SelectionType): string | string[] => (type === 'single' ? '' : []);
12
+
13
+ /**
14
+ * Membership over a bindable that is a string in single mode and a string array in multiple
15
+ * mode. The mode is fixed at construction, so a write always keeps the shape the consumer's
16
+ * prop was declared with.
17
+ */
18
+ export class SelectionValue {
19
+ readonly isMulti: boolean;
20
+ readonly #value: WritableBox<string | string[]>;
21
+
22
+ constructor(type: SelectionType, value: WritableBox<string | string[]>) {
23
+ this.isMulti = type === 'multiple';
24
+ this.#value = value;
25
+ }
26
+
27
+ includes(item: string): boolean {
28
+ const value = this.#value.current;
29
+ return this.isMulti ? (value as string[]).includes(item) : value === item;
30
+ }
31
+
32
+ /** Flips membership and returns whether the item is selected afterwards. */
33
+ toggle(item: string): boolean {
34
+ const selected = !this.includes(item);
35
+ if (this.isMulti) {
36
+ const value = this.#value.current as string[];
37
+ this.#value.current = selected ? [...value, item] : value.filter((v) => v !== item);
38
+ } else {
39
+ this.#value.current = selected ? item : '';
40
+ }
41
+ return selected;
42
+ }
43
+ }
44
+
45
+ /** What a selection item needs from the group that owns it. */
46
+ export interface SelectionGroup {
47
+ readonly selection: SelectionValue;
48
+ readonly disabled: ReadableBox<boolean>;
49
+ readonly orientation: ReadableBox<Orientation>;
50
+ readonly rovingFocusGroup: RovingFocusGroup;
51
+ /** Whether arrow keys move between the items; absent means always. */
52
+ readonly rovingFocus?: ReadableBox<boolean>;
53
+ /** A selection takes the roving tab stop in a toggle group; a toolbar's stays where it is. */
54
+ readonly selectionTakesTabStop: boolean;
55
+ /** Beyond the roving group's candidate attribute, which every item carries. */
56
+ readonly extraItemAttrs?: Record<string, ''>;
57
+ }
58
+
59
+ export interface SelectionItemOpts
60
+ extends
61
+ WithRefOpts,
62
+ ReadableBoxedValues<{
63
+ value: string;
64
+ disabled: boolean;
65
+ }> {}
66
+
67
+ export class SelectionItemState {
68
+ readonly opts: SelectionItemOpts;
69
+ readonly group: SelectionGroup;
70
+ readonly rovingItem: RovingFocusItem;
71
+ readonly #isDisabled = $derived.by(() => this.opts.disabled.current || this.group.disabled.current);
72
+ readonly isPressed = $derived.by(() => this.group.selection.includes(this.opts.value.current));
73
+
74
+ constructor(opts: SelectionItemOpts, group: SelectionGroup) {
75
+ this.opts = opts;
76
+ this.group = group;
77
+ this.rovingItem = new RovingFocusItem({
78
+ group: group.rovingFocusGroup,
79
+ ref: opts.ref,
80
+ enabled: group.rovingFocus,
81
+ });
82
+
83
+ this.onclick = this.onclick.bind(this);
84
+ this.onkeydown = this.onkeydown.bind(this);
85
+ }
86
+
87
+ #activate() {
88
+ if (this.#isDisabled) return;
89
+ const selected = this.group.selection.toggle(this.opts.value.current);
90
+ if (selected && this.group.selectionTakesTabStop) this.group.rovingFocusGroup.setCurrentTabStopId(this.opts.id.current);
91
+ }
92
+
93
+ onclick(_: BitsMouseEvent) {
94
+ this.#activate();
95
+ }
96
+
97
+ onkeydown(e: BitsKeyboardEvent) {
98
+ if (this.#isDisabled) return;
99
+ if (e.key === kbd.ENTER || e.key === kbd.SPACE) {
100
+ e.preventDefault();
101
+ this.#activate();
102
+ return;
103
+ }
104
+ this.rovingItem.handleKeydown(e);
105
+ }
106
+
107
+ readonly snippetProps = $derived.by(() => ({ pressed: this.isPressed }));
108
+
109
+ readonly props = $derived.by(
110
+ () =>
111
+ ({
112
+ id: this.opts.id.current,
113
+ role: this.group.selection.isMulti ? undefined : 'radio',
114
+ 'data-orientation': this.group.orientation.current,
115
+ 'data-disabled': boolToEmptyStrOrUndef(this.#isDisabled),
116
+ 'data-state': this.isPressed ? 'on' : 'off',
117
+ 'data-value': this.opts.value.current,
118
+ 'aria-pressed': this.group.selection.isMulti ? boolToStr(this.isPressed) : undefined,
119
+ 'aria-checked': this.group.selection.isMulti ? undefined : getAriaChecked(this.isPressed, false),
120
+ disabled: boolToTrueOrUndef(this.#isDisabled),
121
+ ...this.group.rovingFocusGroup.candidateAttrs,
122
+ ...this.group.extraItemAttrs,
123
+ //
124
+ onclick: this.onclick,
125
+ onkeydown: this.onkeydown,
126
+ ...this.rovingItem.props,
127
+ }) as const,
128
+ );
129
+ }