sheer-ui 0.1.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 +2 -2
- package/src/lib/components/data-table/table-column-header.svelte +4 -2
- package/src/lib/components/data-table/table-view-options.svelte +1 -1
- package/src/lib/components/menu/components/menu-checkbox-item.svelte +1 -9
- package/src/lib/components/menu/components/menu-item.svelte +0 -2
- package/src/lib/components/menu/components/menu-radio-item.svelte +1 -9
- package/src/lib/components/menu/menu.svelte.ts +30 -33
- package/src/lib/components/menu/types.ts +10 -30
- package/src/lib/components/sidebar/context.svelte.ts +22 -5
- package/src/lib/components/sidebar/sidebar-desktop-surface.svelte +57 -2
- package/src/lib/components/sidebar/sidebar-mobile-surface.svelte +13 -7
- package/src/lib/components/sidebar/sidebar-provider.svelte +2 -2
- package/src/lib/components/sidebar/sidebar.svelte +10 -4
- 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/src/lib/internal/table/table.svelte.ts +3 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sheer-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Svelte 5 UI component library with Tailwind CSS",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -332,7 +332,6 @@
|
|
|
332
332
|
"dependencies": {
|
|
333
333
|
"@lucide/svelte": "^1.18.0",
|
|
334
334
|
"esm-env": "^1.2.2",
|
|
335
|
-
"handful": "^0.3.0",
|
|
336
335
|
"d3-scale": "^4.0.2",
|
|
337
336
|
"layerchart": "^2.0.1",
|
|
338
337
|
"overrule": "^0.7.3"
|
|
@@ -341,6 +340,7 @@
|
|
|
341
340
|
"@changesets/changelog-github": "^1.0.0",
|
|
342
341
|
"@changesets/cli": "^3.0.1",
|
|
343
342
|
"@fontsource-variable/inter": "^5.2.8",
|
|
343
|
+
"handful": "^0.3.0",
|
|
344
344
|
"@sveltejs/adapter-auto": "8.0.0-next.1",
|
|
345
345
|
"@internationalized/date": "^3.12.2",
|
|
346
346
|
"@sveltejs/kit": "^3.0.0-next.25",
|
|
@@ -43,8 +43,10 @@
|
|
|
43
43
|
<DropdownMenu.Content align="start">
|
|
44
44
|
{@render item(() => column.toggleSorting(false), ArrowUpIcon, 'Asc')}
|
|
45
45
|
{@render item(() => column.toggleSorting(true), ArrowDownIcon, 'Desc')}
|
|
46
|
-
|
|
47
|
-
|
|
46
|
+
{#if column.canHide}
|
|
47
|
+
<DropdownMenu.Separator />
|
|
48
|
+
{@render item(() => (column.isVisible = false), EyeOffIcon, 'Hide')}
|
|
49
|
+
{/if}
|
|
48
50
|
</DropdownMenu.Content>
|
|
49
51
|
</DropdownMenu.Root>
|
|
50
52
|
</div>
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
<DropdownMenu.GroupHeading>Toggle columns</DropdownMenu.GroupHeading>
|
|
17
17
|
<DropdownMenu.Separator />
|
|
18
18
|
{#each table.allColumns.filter((col) => typeof col.accessorFn !== 'undefined' && col.canHide) as column (column)}
|
|
19
|
-
<DropdownMenu.CheckboxItem bind:checked={
|
|
19
|
+
<DropdownMenu.CheckboxItem bind:checked={column.isVisible} class="capitalize">
|
|
20
20
|
{column.id}
|
|
21
21
|
</DropdownMenu.CheckboxItem>
|
|
22
22
|
{/each}
|
|
@@ -19,7 +19,6 @@
|
|
|
19
19
|
onCheckedChange = () => {},
|
|
20
20
|
disabled = false,
|
|
21
21
|
onSelect = () => {},
|
|
22
|
-
closeOnSelect = true,
|
|
23
22
|
indeterminate = $bindable(false),
|
|
24
23
|
onIndeterminateChange = () => {},
|
|
25
24
|
value = '',
|
|
@@ -34,12 +33,11 @@
|
|
|
34
33
|
),
|
|
35
34
|
id: boxWith(() => id),
|
|
36
35
|
disabled: boxWith(() => disabled),
|
|
37
|
-
onSelect: boxWith(() =>
|
|
36
|
+
onSelect: boxWith(() => onSelect),
|
|
38
37
|
ref: boxWith(
|
|
39
38
|
() => ref,
|
|
40
39
|
(v) => (ref = v),
|
|
41
40
|
),
|
|
42
|
-
closeOnSelect: boxWith(() => closeOnSelect),
|
|
43
41
|
indeterminate: bindableWith(
|
|
44
42
|
() => indeterminate,
|
|
45
43
|
(v) => (indeterminate = v),
|
|
@@ -50,12 +48,6 @@
|
|
|
50
48
|
|
|
51
49
|
repairBindable(checkboxItemState.groupChecked, () => (checked = checkboxItemState.groupChecked() ?? checked));
|
|
52
50
|
|
|
53
|
-
function handleSelect(e: Event) {
|
|
54
|
-
onSelect(e);
|
|
55
|
-
if (e.defaultPrevented) return;
|
|
56
|
-
checkboxItemState.toggleChecked();
|
|
57
|
-
}
|
|
58
|
-
|
|
59
51
|
const mergedProps = $derived(
|
|
60
52
|
mergeProps(
|
|
61
53
|
{
|
|
@@ -14,7 +14,6 @@
|
|
|
14
14
|
id = createId(uid),
|
|
15
15
|
disabled = false,
|
|
16
16
|
onSelect = () => {},
|
|
17
|
-
closeOnSelect = true,
|
|
18
17
|
inset,
|
|
19
18
|
variant = 'default',
|
|
20
19
|
...restProps
|
|
@@ -31,7 +30,6 @@
|
|
|
31
30
|
() => ref,
|
|
32
31
|
(v) => (ref = v),
|
|
33
32
|
),
|
|
34
|
-
closeOnSelect: boxWith(() => closeOnSelect),
|
|
35
33
|
});
|
|
36
34
|
|
|
37
35
|
const mergedProps = $derived(
|
|
@@ -16,7 +16,6 @@
|
|
|
16
16
|
onSelect = () => {},
|
|
17
17
|
id = createId(uid),
|
|
18
18
|
disabled = false,
|
|
19
|
-
closeOnSelect = true,
|
|
20
19
|
...restProps
|
|
21
20
|
}: MenuRadioItemProps = $props();
|
|
22
21
|
|
|
@@ -24,20 +23,13 @@
|
|
|
24
23
|
value: boxWith(() => value),
|
|
25
24
|
id: boxWith(() => id),
|
|
26
25
|
disabled: boxWith(() => disabled),
|
|
27
|
-
onSelect: boxWith(() =>
|
|
26
|
+
onSelect: boxWith(() => onSelect),
|
|
28
27
|
ref: boxWith(
|
|
29
28
|
() => ref,
|
|
30
29
|
(v) => (ref = v),
|
|
31
30
|
),
|
|
32
|
-
closeOnSelect: boxWith(() => closeOnSelect),
|
|
33
31
|
});
|
|
34
32
|
|
|
35
|
-
function handleSelect(e: Event) {
|
|
36
|
-
onSelect(e);
|
|
37
|
-
if (e.defaultPrevented) return;
|
|
38
|
-
radioItemState.selectValue();
|
|
39
|
-
}
|
|
40
|
-
|
|
41
33
|
const mergedProps = $derived(
|
|
42
34
|
mergeProps(
|
|
43
35
|
{
|
|
@@ -924,24 +924,31 @@ type MenuItemCombinedProps = MenuItemSharedStateOpts & MenuItemStateOpts;
|
|
|
924
924
|
|
|
925
925
|
interface MenuItemStateOpts extends ReadableBoxedValues<{
|
|
926
926
|
onSelect: AnyFn;
|
|
927
|
-
closeOnSelect: boolean;
|
|
928
927
|
}> {}
|
|
929
928
|
|
|
929
|
+
/** What an item kind does when selected: its own action, and whether the menu closes afterwards. */
|
|
930
|
+
interface MenuItemSelect {
|
|
931
|
+
closes: boolean;
|
|
932
|
+
act?: () => void;
|
|
933
|
+
}
|
|
934
|
+
|
|
930
935
|
export class MenuItemState {
|
|
931
936
|
static create(opts: MenuItemCombinedProps) {
|
|
932
937
|
const item = new MenuItemSharedState(opts, getMenuContent());
|
|
933
|
-
return new MenuItemState(opts, item);
|
|
938
|
+
return new MenuItemState(opts, item, { closes: true });
|
|
934
939
|
}
|
|
935
940
|
|
|
936
941
|
readonly opts: MenuItemStateOpts;
|
|
937
942
|
readonly item: MenuItemSharedState;
|
|
938
943
|
readonly root: MenuRootState;
|
|
944
|
+
readonly #select: MenuItemSelect;
|
|
939
945
|
#isPointerDown = false;
|
|
940
946
|
|
|
941
|
-
constructor(opts: MenuItemStateOpts, item: MenuItemSharedState) {
|
|
947
|
+
constructor(opts: MenuItemStateOpts, item: MenuItemSharedState, select: MenuItemSelect) {
|
|
942
948
|
this.opts = opts;
|
|
943
949
|
this.item = item;
|
|
944
950
|
this.root = item.content.parentMenu.root;
|
|
951
|
+
this.#select = select;
|
|
945
952
|
|
|
946
953
|
this.onkeydown = this.onkeydown.bind(this);
|
|
947
954
|
this.onclick = this.onclick.bind(this);
|
|
@@ -949,17 +956,13 @@ export class MenuItemState {
|
|
|
949
956
|
this.onpointerup = this.onpointerup.bind(this);
|
|
950
957
|
}
|
|
951
958
|
|
|
959
|
+
// The consumer's onSelect runs first; preventDefault only keeps the menu open, the item's own action always runs.
|
|
952
960
|
#handleSelect() {
|
|
953
961
|
if (this.item.opts.disabled.current) return;
|
|
954
962
|
const selectEvent = new CustomEvent('menuitemselect', { bubbles: true, cancelable: true });
|
|
955
963
|
this.opts.onSelect.current(selectEvent);
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
return;
|
|
959
|
-
}
|
|
960
|
-
if (this.opts.closeOnSelect.current) {
|
|
961
|
-
this.item.content.parentMenu.root.opts.onClose();
|
|
962
|
-
}
|
|
964
|
+
this.#select.act?.();
|
|
965
|
+
if (this.#select.closes && !selectEvent.defaultPrevented) this.root.opts.onClose();
|
|
963
966
|
}
|
|
964
967
|
|
|
965
968
|
onkeydown(e: BitsKeyboardEvent) {
|
|
@@ -983,12 +986,14 @@ export class MenuItemState {
|
|
|
983
986
|
this.#handleSelect();
|
|
984
987
|
}
|
|
985
988
|
|
|
989
|
+
// A press that started elsewhere (on the trigger, dragged here) gets its click synthesised; a
|
|
990
|
+
// press that started here gets the native one. The content stays mounted across openings and
|
|
991
|
+
// a checkbox item outlives many selects, so the flag resets per press.
|
|
986
992
|
onpointerup(e: BitsPointerEvent) {
|
|
987
993
|
if (e.defaultPrevented) return;
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
}
|
|
994
|
+
const pressedHere = this.#isPointerDown;
|
|
995
|
+
this.#isPointerDown = false;
|
|
996
|
+
if (!pressedHere && isHTMLElement(e.currentTarget)) e.currentTarget.click();
|
|
992
997
|
}
|
|
993
998
|
|
|
994
999
|
onpointerdown(_: BitsPointerEvent) {
|
|
@@ -1124,21 +1129,20 @@ interface MenuCheckboxItemStateOpts
|
|
|
1124
1129
|
|
|
1125
1130
|
export class MenuCheckboxItemState {
|
|
1126
1131
|
static create(opts: MenuItemCombinedProps & MenuCheckboxItemStateOpts) {
|
|
1127
|
-
|
|
1128
|
-
return new MenuCheckboxItemState(opts, item);
|
|
1132
|
+
return new MenuCheckboxItemState(opts, new MenuItemSharedState(opts, getMenuContent()));
|
|
1129
1133
|
}
|
|
1130
1134
|
|
|
1131
1135
|
readonly opts: MenuCheckboxItemStateOpts;
|
|
1132
1136
|
readonly item: MenuItemState;
|
|
1133
1137
|
readonly groupChecked: () => boolean | undefined;
|
|
1134
1138
|
|
|
1135
|
-
constructor(opts: MenuCheckboxItemStateOpts,
|
|
1139
|
+
constructor(opts: MenuItemCombinedProps & MenuCheckboxItemStateOpts, shared: MenuItemSharedState) {
|
|
1136
1140
|
this.opts = opts;
|
|
1137
|
-
this.item =
|
|
1141
|
+
this.item = new MenuItemState(opts, shared, { closes: false, act: () => this.#toggle() });
|
|
1138
1142
|
this.groupChecked = joinGroup(hasMenuCheckboxGroup() ? getMenuCheckboxGroup() : null, opts);
|
|
1139
1143
|
}
|
|
1140
1144
|
|
|
1141
|
-
|
|
1145
|
+
#toggle() {
|
|
1142
1146
|
if (this.opts.indeterminate.current) {
|
|
1143
1147
|
this.opts.indeterminate.current = false;
|
|
1144
1148
|
this.opts.checked.current = true;
|
|
@@ -1293,6 +1297,7 @@ export class MenuRadioGroupState {
|
|
|
1293
1297
|
}
|
|
1294
1298
|
|
|
1295
1299
|
setValue(v: string) {
|
|
1300
|
+
if (this.opts.value.current === v) return;
|
|
1296
1301
|
this.opts.value.current = v;
|
|
1297
1302
|
}
|
|
1298
1303
|
|
|
@@ -1313,31 +1318,24 @@ interface MenuRadioItemStateOpts
|
|
|
1313
1318
|
WithRefOpts,
|
|
1314
1319
|
ReadableBoxedValues<{
|
|
1315
1320
|
value: string;
|
|
1316
|
-
closeOnSelect: boolean;
|
|
1317
1321
|
}> {}
|
|
1318
1322
|
|
|
1319
1323
|
export class MenuRadioItemState {
|
|
1320
1324
|
static create(opts: MenuRadioItemStateOpts & MenuItemCombinedProps) {
|
|
1321
|
-
|
|
1322
|
-
const sharedItem = new MenuItemSharedState(opts, radioGroup.content);
|
|
1323
|
-
const item = new MenuItemState(opts, sharedItem);
|
|
1324
|
-
return new MenuRadioItemState(opts, item, radioGroup);
|
|
1325
|
+
return new MenuRadioItemState(opts, getMenuRadioGroup());
|
|
1325
1326
|
}
|
|
1326
1327
|
readonly opts: MenuRadioItemStateOpts;
|
|
1327
1328
|
readonly item: MenuItemState;
|
|
1328
1329
|
readonly group: MenuRadioGroupState;
|
|
1329
|
-
readonly attachment: RefAttachment;
|
|
1330
1330
|
readonly isChecked = $derived.by(() => this.group.opts.value.current === this.opts.value.current);
|
|
1331
1331
|
|
|
1332
|
-
constructor(opts: MenuRadioItemStateOpts
|
|
1332
|
+
constructor(opts: MenuRadioItemStateOpts & MenuItemCombinedProps, group: MenuRadioGroupState) {
|
|
1333
1333
|
this.opts = opts;
|
|
1334
|
-
this.item = item;
|
|
1335
1334
|
this.group = group;
|
|
1336
|
-
this.
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
this.group.setValue(this.opts.value.current);
|
|
1335
|
+
this.item = new MenuItemState(opts, new MenuItemSharedState(opts, group.content), {
|
|
1336
|
+
closes: true,
|
|
1337
|
+
act: () => group.setValue(opts.value.current),
|
|
1338
|
+
});
|
|
1341
1339
|
}
|
|
1342
1340
|
|
|
1343
1341
|
readonly props = $derived.by(
|
|
@@ -1348,7 +1346,6 @@ export class MenuRadioItemState {
|
|
|
1348
1346
|
role: 'menuitemradio',
|
|
1349
1347
|
'aria-checked': getAriaChecked(this.isChecked, false),
|
|
1350
1348
|
'data-state': getCheckedState(this.isChecked),
|
|
1351
|
-
...this.attachment,
|
|
1352
1349
|
}) as const,
|
|
1353
1350
|
);
|
|
1354
1351
|
}
|
|
@@ -81,27 +81,13 @@ export type MenuItemPropsWithoutHTML<U extends Record<PropertyKey, unknown> = {
|
|
|
81
81
|
*/
|
|
82
82
|
disabled?: boolean;
|
|
83
83
|
|
|
84
|
-
/**
|
|
85
|
-
* Optional text to use for typeahead filtering. By default, typeahead will use
|
|
86
|
-
* the `.textContent` of the menu item. When the content is more complex, you
|
|
87
|
-
* can provide a string here instead.
|
|
88
|
-
*
|
|
89
|
-
* @defaultValue undefined
|
|
90
|
-
*/
|
|
91
|
-
textValue?: string;
|
|
92
|
-
|
|
93
84
|
/**
|
|
94
85
|
* A callback fired when the menu item is selected.
|
|
95
86
|
*
|
|
96
|
-
*
|
|
87
|
+
* Calling `event.preventDefault()` keeps a plain or radio item from closing the
|
|
88
|
+
* menu; the radio item still selects. A checkbox item never closes it.
|
|
97
89
|
*/
|
|
98
90
|
onSelect?: (event: Event) => void;
|
|
99
|
-
|
|
100
|
-
/**
|
|
101
|
-
* Whether or not the menu item should close when selected.
|
|
102
|
-
* @defaultValue true
|
|
103
|
-
*/
|
|
104
|
-
closeOnSelect?: boolean;
|
|
105
91
|
},
|
|
106
92
|
U
|
|
107
93
|
>;
|
|
@@ -137,13 +123,6 @@ export type MenuCheckboxItemPropsWithoutHTML = MenuItemPropsWithoutHTML<MenuChec
|
|
|
137
123
|
*/
|
|
138
124
|
onIndeterminateChange?: OnChangeFn<boolean>;
|
|
139
125
|
|
|
140
|
-
/**
|
|
141
|
-
* Whether or not the menu item should close when selected.
|
|
142
|
-
*
|
|
143
|
-
* @defaultValue true
|
|
144
|
-
*/
|
|
145
|
-
closeOnSelect?: boolean;
|
|
146
|
-
|
|
147
126
|
/**
|
|
148
127
|
* The value of the checkbox item when used in a checkbox group.
|
|
149
128
|
*/
|
|
@@ -219,7 +198,14 @@ export type MenuSubContentStaticPropsWithoutHTML = Expand<
|
|
|
219
198
|
export type MenuSubContentStaticProps = MenuSubContentStaticPropsWithoutHTML &
|
|
220
199
|
Without<BitsPrimitiveDivAttributes, MenuSubContentStaticPropsWithoutHTML>;
|
|
221
200
|
|
|
222
|
-
export type MenuSubTriggerPropsWithoutHTML = Omit<MenuItemPropsWithoutHTML, '
|
|
201
|
+
export type MenuSubTriggerPropsWithoutHTML = Omit<MenuItemPropsWithoutHTML, 'onSelect'> & {
|
|
202
|
+
/**
|
|
203
|
+
* A callback fired when the sub-trigger is selected, right before its submenu opens.
|
|
204
|
+
* `event.preventDefault()` has no effect here: the submenu opens either way and the
|
|
205
|
+
* parent menu never closes on a sub-trigger.
|
|
206
|
+
*/
|
|
207
|
+
onSelect?: (event: Event) => void;
|
|
208
|
+
|
|
223
209
|
/**
|
|
224
210
|
* The amount of time in ms from when the mouse enters the subtrigger until
|
|
225
211
|
* the submenu opens. This is useful for preventing the submenu from opening
|
|
@@ -271,12 +257,6 @@ export type MenuRadioItemPropsWithoutHTML = MenuItemPropsWithoutHTML<MenuRadioIt
|
|
|
271
257
|
* The value of the radio item.
|
|
272
258
|
*/
|
|
273
259
|
value: string;
|
|
274
|
-
|
|
275
|
-
/**
|
|
276
|
-
* Whether or not the menu item should close when selected.
|
|
277
|
-
* @defaultValue true
|
|
278
|
-
*/
|
|
279
|
-
closeOnSelect?: boolean;
|
|
280
260
|
};
|
|
281
261
|
|
|
282
262
|
export type MenuRadioItemProps = MenuRadioItemPropsWithoutHTML & Without<BitsPrimitiveDivAttributes, MenuRadioItemPropsWithoutHTML>;
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { createContext } from "svelte";
|
|
2
|
-
import { isMobile as viewportIsMobile } from 'handful';
|
|
3
2
|
import { SIDEBAR_KEYBOARD_SHORTCUT } from './constants';
|
|
4
3
|
|
|
5
4
|
function isEditableTarget(target: EventTarget | null) {
|
|
@@ -23,16 +22,30 @@ type SidebarStateProps = {
|
|
|
23
22
|
setOpen: (open: boolean) => void;
|
|
24
23
|
};
|
|
25
24
|
|
|
25
|
+
/** Whether the `md:` rule on the desktop panel is in effect: the panel's own display, not an ancestor's. */
|
|
26
|
+
export const panelDisplayed = (panel: HTMLElement) => getComputedStyle(panel).display !== 'none';
|
|
27
|
+
|
|
26
28
|
export class SidebarState {
|
|
27
29
|
readonly props: SidebarStateProps;
|
|
28
30
|
open = $derived.by(() => this.props.open());
|
|
29
|
-
|
|
30
|
-
|
|
31
|
+
sheetOpen = $state(false);
|
|
32
|
+
/**
|
|
33
|
+
* The mounted desktop panels (one per Root; a Provider may hold several). CSS displays a panel
|
|
34
|
+
* or its sheet per viewport, so whether a panel is displayed IS the viewport: a toggle writes
|
|
35
|
+
* the persisted `open` while it is, and the sheet's bit while it isn't. No media query is read
|
|
36
|
+
* in JS; the breakpoint lives only in the panel's `md:` classes.
|
|
37
|
+
*/
|
|
38
|
+
readonly desktopPanels = new Set<HTMLElement>();
|
|
31
39
|
|
|
32
40
|
constructor(props: SidebarStateProps) {
|
|
33
41
|
this.props = props;
|
|
34
42
|
}
|
|
35
43
|
|
|
44
|
+
#onDesktop() {
|
|
45
|
+
for (const panel of this.desktopPanels) return panelDisplayed(panel);
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
|
|
36
49
|
// Event handler to apply to the `<svelte:window>`
|
|
37
50
|
handleShortcutKeydown = (e: KeyboardEvent) => {
|
|
38
51
|
if (e.defaultPrevented || e.repeat || isEditableTarget(e.target)) return;
|
|
@@ -43,11 +56,15 @@ export class SidebarState {
|
|
|
43
56
|
};
|
|
44
57
|
|
|
45
58
|
setOpen = (value: boolean) => {
|
|
46
|
-
|
|
59
|
+
this.#onDesktop() ? this.props.setOpen(value) : (this.sheetOpen = value);
|
|
47
60
|
};
|
|
48
61
|
|
|
49
62
|
toggle = () => {
|
|
50
|
-
this.setOpen(!this.
|
|
63
|
+
this.#onDesktop() ? this.props.setOpen(!this.open) : (this.sheetOpen = !this.sheetOpen);
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
closeSheet = () => {
|
|
67
|
+
this.sheetOpen = false;
|
|
51
68
|
};
|
|
52
69
|
}
|
|
53
70
|
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { join } from 'overrule';
|
|
3
|
-
import { useSidebar } from './context.svelte.js';
|
|
3
|
+
import { panelDisplayed, useSidebar } from './context.svelte.js';
|
|
4
|
+
import { resizeAttachment } from '../../internal/svelte-resize-observer.svelte.js';
|
|
4
5
|
import type { SidebarRootProps } from './types.js';
|
|
6
|
+
import type { Attachment } from 'svelte/attachments';
|
|
5
7
|
|
|
6
8
|
let {
|
|
7
9
|
ref = $bindable(null),
|
|
@@ -10,14 +12,65 @@
|
|
|
10
12
|
collapsible = 'offcanvas',
|
|
11
13
|
class: className,
|
|
12
14
|
children,
|
|
15
|
+
sheetBody = null,
|
|
16
|
+
sheetDialog = null,
|
|
13
17
|
...restProps
|
|
14
|
-
}: SidebarRootProps
|
|
18
|
+
}: SidebarRootProps & {
|
|
19
|
+
/** This Root's sheet, bound by the Root: where the content goes while CSS hides this panel. */
|
|
20
|
+
sheetBody?: HTMLElement | null;
|
|
21
|
+
sheetDialog?: HTMLDialogElement | null;
|
|
22
|
+
} = $props();
|
|
15
23
|
|
|
16
24
|
const sidebar = useSidebar();
|
|
25
|
+
|
|
26
|
+
let container: HTMLElement;
|
|
27
|
+
let inner: HTMLElement;
|
|
28
|
+
|
|
29
|
+
// The content tree renders once, here, server-side always here, and lives in whichever
|
|
30
|
+
// surface CSS displays: this panel's own display (not an ancestor's) is the viewport.
|
|
31
|
+
const rehome = (panel: HTMLElement) => {
|
|
32
|
+
const displayed = panelDisplayed(panel);
|
|
33
|
+
const home = displayed ? container : sheetBody;
|
|
34
|
+
// append() re-inserts even in place, which would blur focus and restart transitions.
|
|
35
|
+
if (home && inner.parentNode !== home) home.append(inner);
|
|
36
|
+
if (!displayed) return;
|
|
37
|
+
// A sheet left open at the crossing goes the way it did when the surface unmounted:
|
|
38
|
+
// closed this frame, no exit slide, the page interactive at once. Routing through state
|
|
39
|
+
// would keep the page inert behind an empty sheet for the 300ms exit.
|
|
40
|
+
if (sheetDialog?.open) sheetDialog.close();
|
|
41
|
+
sidebar.closeSheet();
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// The observer fires when `hidden md:block` flips this box between 0×0 and laid out, which is
|
|
45
|
+
// the breakpoint crossing. It also fires every frame of the 200ms width transition, so a
|
|
46
|
+
// report that leaves the display as it was is dropped before the style read.
|
|
47
|
+
let lastDisplayed: boolean | undefined;
|
|
48
|
+
const observe = resizeAttachment(([entry]) => {
|
|
49
|
+
if (!entry) return;
|
|
50
|
+
const panel = entry.target as HTMLElement;
|
|
51
|
+
const displayed = panelDisplayed(panel);
|
|
52
|
+
if (displayed === lastDisplayed) return;
|
|
53
|
+
lastDisplayed = displayed;
|
|
54
|
+
rehome(panel);
|
|
55
|
+
});
|
|
56
|
+
const register: Attachment<HTMLElement> = (node) => {
|
|
57
|
+
sidebar.desktopPanels.add(node);
|
|
58
|
+
return () => sidebar.desktopPanels.delete(node);
|
|
59
|
+
};
|
|
60
|
+
// A display:none panel gets no initial observation, and a crossing made while an ancestor is
|
|
61
|
+
// display:none reports no size change (0×0 either way), so the content can be parked in the
|
|
62
|
+
// wrong surface while nothing shows it; the sheet opening is the moment that matters, and
|
|
63
|
+
// re-homes. A pre effect so it runs before the sheet's showModal(), which picks the initial
|
|
64
|
+
// focus from whatever the dialog holds at that moment.
|
|
65
|
+
$effect.pre(() => {
|
|
66
|
+
if (sidebar.sheetOpen && ref) rehome(ref);
|
|
67
|
+
});
|
|
17
68
|
</script>
|
|
18
69
|
|
|
19
70
|
<div
|
|
20
71
|
bind:this={ref}
|
|
72
|
+
{@attach register}
|
|
73
|
+
{...observe}
|
|
21
74
|
class="text-sidebar-foreground group peer hidden md:block"
|
|
22
75
|
data-state={sidebar.open ? 'expanded' : 'collapsed'}
|
|
23
76
|
data-collapsible={sidebar.open ? '' : collapsible}
|
|
@@ -37,6 +90,7 @@
|
|
|
37
90
|
)}>
|
|
38
91
|
</div>
|
|
39
92
|
<div
|
|
93
|
+
bind:this={container}
|
|
40
94
|
data-slot="sidebar-container"
|
|
41
95
|
class={join(
|
|
42
96
|
'fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex',
|
|
@@ -51,6 +105,7 @@
|
|
|
51
105
|
)}
|
|
52
106
|
{...restProps}>
|
|
53
107
|
<div
|
|
108
|
+
bind:this={inner}
|
|
54
109
|
data-sidebar="sidebar"
|
|
55
110
|
data-slot="sidebar-inner"
|
|
56
111
|
class="bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:shadow-sm group-data-[variant=floating]:ring-1 group-data-[variant=floating]:ring-sidebar-border flex size-full flex-col">
|
|
@@ -8,18 +8,26 @@
|
|
|
8
8
|
import { useSidebar } from './context.svelte.js';
|
|
9
9
|
import type { SidebarRootProps } from './types.js';
|
|
10
10
|
|
|
11
|
-
let {
|
|
11
|
+
let {
|
|
12
|
+
ref = $bindable(null),
|
|
13
|
+
body = $bindable(null),
|
|
14
|
+
side = 'left',
|
|
15
|
+
class: className,
|
|
16
|
+
}: Pick<SidebarRootProps, 'ref' | 'side' | 'class'> & {
|
|
17
|
+
/** An empty shell: the desktop panel moves the content tree here while CSS hides the panel. */
|
|
18
|
+
body?: HTMLElement | null;
|
|
19
|
+
} = $props();
|
|
12
20
|
|
|
13
21
|
const sidebar = useSidebar();
|
|
14
22
|
|
|
15
|
-
// SidebarState is an external machine that owns
|
|
23
|
+
// SidebarState is an external machine that owns the sheet's bit; the sheet cell
|
|
16
24
|
// derives from it, and sheet-initiated dismissals route back through it.
|
|
17
25
|
// This surface acts as its own sheet root so the cell stays internal.
|
|
18
|
-
const sheetCell = new DialogState(() => sidebar.
|
|
26
|
+
const sheetCell = new DialogState(() => sidebar.sheetOpen);
|
|
19
27
|
$effect(() => {
|
|
20
28
|
const o = sheetCell.open;
|
|
21
29
|
untrack(() => {
|
|
22
|
-
if (o !== sidebar.
|
|
30
|
+
if (o !== sidebar.sheetOpen) sidebar.sheetOpen = o;
|
|
23
31
|
});
|
|
24
32
|
});
|
|
25
33
|
|
|
@@ -42,7 +50,5 @@
|
|
|
42
50
|
<Sheet.Title>Sidebar</Sheet.Title>
|
|
43
51
|
<Sheet.Description>Displays the mobile sidebar.</Sheet.Description>
|
|
44
52
|
</Sheet.Header>
|
|
45
|
-
<div class="flex h-full w-full flex-col">
|
|
46
|
-
{@render children?.()}
|
|
47
|
-
</div>
|
|
53
|
+
<div class="flex h-full w-full flex-col" bind:this={body}></div>
|
|
48
54
|
</Sheet.Content>
|
|
@@ -7,7 +7,6 @@
|
|
|
7
7
|
import { SIDEBAR_COOKIE_MAX_AGE, SIDEBAR_COOKIE_NAME, SIDEBAR_WIDTH, SIDEBAR_WIDTH_ICON } from './constants';
|
|
8
8
|
import { setSidebar, SidebarState } from './context.svelte';
|
|
9
9
|
import { OpenCell } from '../../internal/open-cell.svelte.js';
|
|
10
|
-
import { isMobile } from 'handful';
|
|
11
10
|
import type { Snippet } from 'svelte';
|
|
12
11
|
|
|
13
12
|
let {
|
|
@@ -43,7 +42,8 @@
|
|
|
43
42
|
);
|
|
44
43
|
|
|
45
44
|
// Closes the mobile sidebar on navigation, since clicking a nav link doesn't dismiss it.
|
|
46
|
-
|
|
45
|
+
// The sheet is only ever open on a phone, so no viewport check.
|
|
46
|
+
beforeNavigate(sidebar.closeSheet);
|
|
47
47
|
</script>
|
|
48
48
|
|
|
49
49
|
<svelte:window onkeydown={sidebar.handleShortcutKeydown} />
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { join } from 'overrule';
|
|
3
|
-
import { isMobile as viewportIsMobile } from 'handful';
|
|
4
3
|
import MobileSurface from './sidebar-mobile-surface.svelte';
|
|
5
4
|
import DesktopSurface from './sidebar-desktop-surface.svelte';
|
|
6
5
|
import type { SidebarRootProps } from './types.js';
|
|
@@ -14,6 +13,10 @@
|
|
|
14
13
|
children,
|
|
15
14
|
...restProps
|
|
16
15
|
}: SidebarRootProps = $props();
|
|
16
|
+
|
|
17
|
+
// This Root's sheet, handed to its panel so several Roots in one Provider stay paired.
|
|
18
|
+
let sheetBody = $state<HTMLElement | null>(null);
|
|
19
|
+
let sheetDialog = $state<HTMLDialogElement | null>(null);
|
|
17
20
|
</script>
|
|
18
21
|
|
|
19
22
|
{#if collapsible === 'none'}
|
|
@@ -23,8 +26,11 @@
|
|
|
23
26
|
{...restProps}>
|
|
24
27
|
{@render children?.()}
|
|
25
28
|
</div>
|
|
26
|
-
{:else if viewportIsMobile.current}
|
|
27
|
-
<MobileSurface bind:ref {side} class={className} {children} {...restProps} />
|
|
28
29
|
{:else}
|
|
29
|
-
|
|
30
|
+
<!-- Both surfaces render on every viewport and server render alike; the sheet is a closed <dialog>
|
|
31
|
+
and the panel is `hidden md:block`, so CSS shows one and nothing swaps at hydration. The
|
|
32
|
+
content renders once, in the panel, which moves it into the sheet while CSS hides the panel.
|
|
33
|
+
`ref` is the panel; the sheet is reachable as [data-mobile]. -->
|
|
34
|
+
<MobileSurface bind:ref={sheetDialog} bind:body={sheetBody} {side} class={className} />
|
|
35
|
+
<DesktopSurface bind:ref {side} {variant} {collapsible} class={className} {children} {sheetBody} {sheetDialog} {...restProps} />
|
|
30
36
|
{/if}
|
|
@@ -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),
|