sheer-ui 0.2.0 → 0.3.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/package.json +1 -1
- package/src/lib/assets/ui.css +22 -0
- package/src/lib/components/toggle-group/components/toggle-group-item.svelte +2 -2
- package/src/lib/components/toggle-group/components/toggle-group.svelte +14 -21
- package/src/lib/components/toggle-group/toggle-group.svelte.ts +34 -191
- package/src/lib/components/toolbar/components/toolbar-group-item.svelte +4 -4
- package/src/lib/components/toolbar/components/toolbar-group.svelte +14 -21
- package/src/lib/components/toolbar/toolbar.svelte.ts +35 -194
- package/src/lib/internal/roving-focus-group.ts +3 -0
- package/src/lib/internal/selection.svelte.ts +129 -0
package/package.json
CHANGED
package/src/lib/assets/ui.css
CHANGED
|
@@ -569,6 +569,28 @@ body:has([data-text-selection-guard]) {
|
|
|
569
569
|
}
|
|
570
570
|
}
|
|
571
571
|
|
|
572
|
+
/* Without anchor positioning (iOS Safari ≤18, Firefox ESR 140; ~11% 2026-08) every surface
|
|
573
|
+
centers on screen instead of hanging off its trigger. The UA popover default already
|
|
574
|
+
centers (inset 0, auto margins, fit-content box), but preflight's `* { margin: 0 }` sends it
|
|
575
|
+
to the top-left and a consumer's `w-auto!` stretches it edge to edge, since an auto width
|
|
576
|
+
between two insets is solved, not shrunk; the fit-content caps restore the box. The JS
|
|
577
|
+
floating surfaces carry inline `inset: auto` and collision margins, hence the importants.
|
|
578
|
+
Delete once ESR 140 is out of support and iOS 18 is under 1%. */
|
|
579
|
+
@supports not (anchor-name: --a) {
|
|
580
|
+
[data-anchored] {
|
|
581
|
+
inset: 0;
|
|
582
|
+
margin: auto;
|
|
583
|
+
max-width: fit-content;
|
|
584
|
+
height: fit-content;
|
|
585
|
+
}
|
|
586
|
+
[data-floating-content] {
|
|
587
|
+
inset: 0 !important;
|
|
588
|
+
margin: auto !important;
|
|
589
|
+
max-width: fit-content;
|
|
590
|
+
height: fit-content;
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
572
594
|
/* An exiting anchored surface (still shown while its [data-state=closed] fade runs, hidden by
|
|
573
595
|
the deferred hidePopover) must not catch the pointer. Shared here, not restated per surface. */
|
|
574
596
|
[data-anchored][data-state='closed'] {
|
|
@@ -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 {
|
|
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 =
|
|
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 {
|
|
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:
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
() =>
|
|
61
|
+
() => value ?? emptySelection(valueType),
|
|
69
62
|
(v) => {
|
|
70
63
|
value = v;
|
|
71
|
-
//
|
|
72
|
-
onValueChange(v);
|
|
64
|
+
// oxlint-disable-next-line no-explicit-any
|
|
65
|
+
onValueChange(v as any);
|
|
73
66
|
},
|
|
74
|
-
)
|
|
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
|
|
3
|
-
import { createBitsAttrs,
|
|
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 {
|
|
5
|
+
import type { RefAttachment, WithRefOpts } from '../../internal/types.js';
|
|
7
6
|
import { RovingFocusGroup } from '../../internal/roving-focus-group.js';
|
|
8
|
-
import {
|
|
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<
|
|
20
|
+
const [getToggleGroupRoot, setToggleGroupRoot] = createContext<ToggleGroupRootState>();
|
|
16
21
|
|
|
17
|
-
interface
|
|
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
|
-
|
|
28
|
-
|
|
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:
|
|
48
|
+
constructor(opts: ToggleGroupRootStateOpts) {
|
|
33
49
|
this.opts = opts;
|
|
34
|
-
this.
|
|
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
|
-
|
|
57
|
-
|
|
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 {
|
|
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 =
|
|
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,
|
|
51
|
+
{@render child({ props: mergedProps, ...groupItemState.snippetProps })}
|
|
52
52
|
{:else}
|
|
53
53
|
<button {...mergedProps}>
|
|
54
|
-
{@render children?.(
|
|
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 {
|
|
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:
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
() =>
|
|
40
|
+
() => value ?? emptySelection(valueType),
|
|
48
41
|
(v) => {
|
|
49
42
|
value = v;
|
|
50
|
-
//
|
|
51
|
-
onValueChange(v);
|
|
43
|
+
// oxlint-disable-next-line no-explicit-any
|
|
44
|
+
onValueChange(v as any);
|
|
52
45
|
},
|
|
53
|
-
)
|
|
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
|
|
3
|
-
import { createBitsAttrs,
|
|
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,
|
|
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<
|
|
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
|
|
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
|
-
|
|
65
|
-
|
|
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:
|
|
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
|
-
|
|
89
|
-
|
|
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
|
+
}
|