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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sheer-ui",
3
- "version": "0.1.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
- <DropdownMenu.Separator />
47
- {@render item(() => column.toggleVisibility(false), EyeOffIcon, 'Hide')}
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={() => column.isVisible, (v) => column.toggleVisibility(!!v)} class="capitalize">
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(() => handleSelect),
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(() => handleSelect),
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
- if (selectEvent.defaultPrevented) {
957
- this.item.content.parentMenu.root.isKeyboard = false;
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
- if (!this.#isPointerDown) {
989
- if (!isHTMLElement(e.currentTarget)) return;
990
- e.currentTarget?.click();
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
- const item = new MenuItemState(opts, new MenuItemSharedState(opts, getMenuContent()));
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, item: MenuItemState) {
1139
+ constructor(opts: MenuItemCombinedProps & MenuCheckboxItemStateOpts, shared: MenuItemSharedState) {
1136
1140
  this.opts = opts;
1137
- this.item = 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
- toggleChecked() {
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
- const radioGroup = getMenuRadioGroup();
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, item: MenuItemState, group: MenuRadioGroupState) {
1332
+ constructor(opts: MenuRadioItemStateOpts & MenuItemCombinedProps, group: MenuRadioGroupState) {
1333
1333
  this.opts = opts;
1334
- this.item = item;
1335
1334
  this.group = group;
1336
- this.attachment = attachRef(this.opts.ref);
1337
- }
1338
-
1339
- selectValue() {
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
- * Prevent default behavior of selection with `event.preventDefault()`.
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, 'closeOnSelect'> & {
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
- #openMobile = $state(false);
30
- openForViewport = $derived.by(() => (viewportIsMobile.current ? this.#openMobile : this.open));
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
- viewportIsMobile.current ? (this.#openMobile = value) : this.props.setOpen(value);
59
+ this.#onDesktop() ? this.props.setOpen(value) : (this.sheetOpen = value);
47
60
  };
48
61
 
49
62
  toggle = () => {
50
- this.setOpen(!this.openForViewport);
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 = $props();
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 { ref = $bindable(null), side = 'left', class: className, children }: SidebarRootProps = $props();
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 `open`; the sheet cell
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.openForViewport);
26
+ const sheetCell = new DialogState(() => sidebar.sheetOpen);
19
27
  $effect(() => {
20
28
  const o = sheetCell.open;
21
29
  untrack(() => {
22
- if (o !== sidebar.openForViewport) sidebar.setOpen(o);
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
- beforeNavigate(() => isMobile.current && sidebar.openForViewport && sidebar.setOpen(false));
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
- <DesktopSurface bind:ref {side} {variant} {collapsible} class={className} {children} {...restProps} />
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 { 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),