@spaethtech/svelte-ui 0.1.10 → 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/README.md +166 -42
- package/dist/components/Alert.svelte +119 -0
- package/dist/components/Alert.svelte.d.ts +29 -0
- package/dist/components/Badge/Badge.svelte +142 -69
- package/dist/components/Badge/Badge.svelte.d.ts +26 -6
- package/dist/components/Badge/index.d.ts +1 -1
- package/dist/components/Badge/index.js +1 -1
- package/dist/components/Banner.svelte +133 -0
- package/dist/components/Banner.svelte.d.ts +31 -0
- package/dist/components/Button.svelte +240 -0
- package/dist/components/Button.svelte.d.ts +33 -0
- package/dist/components/ButtonDropdown.svelte +145 -0
- package/dist/components/ButtonDropdown.svelte.d.ts +31 -0
- package/dist/components/Calendar.svelte +259 -0
- package/dist/components/Calendar.svelte.d.ts +27 -0
- package/dist/components/Card.svelte +78 -0
- package/dist/components/Card.svelte.d.ts +17 -0
- package/dist/components/CardBody.svelte +41 -0
- package/dist/components/CardBody.svelte.d.ts +17 -0
- package/dist/components/CardFooter.svelte +49 -0
- package/dist/components/CardFooter.svelte.d.ts +16 -0
- package/dist/components/CardHeader.svelte +49 -0
- package/dist/components/CardHeader.svelte.d.ts +16 -0
- package/dist/components/Checkbox.svelte +131 -0
- package/dist/components/Checkbox.svelte.d.ts +16 -0
- package/dist/components/DataTable.svelte +518 -0
- package/dist/components/DataTable.svelte.d.ts +74 -0
- package/dist/components/DatePicker.svelte +312 -0
- package/dist/components/DatePicker.svelte.d.ts +32 -0
- package/dist/components/DateTimeInput.svelte +93 -0
- package/dist/components/DateTimeInput.svelte.d.ts +20 -0
- package/dist/components/Dialog.svelte +132 -0
- package/dist/components/Dialog.svelte.d.ts +24 -0
- package/dist/components/{EmailInput/EmailInput.svelte → EmailInput.svelte} +17 -8
- package/dist/components/EmailInput.svelte.d.ts +21 -0
- package/dist/components/Input.svelte +369 -0
- package/dist/components/Input.svelte.d.ts +35 -0
- package/dist/components/{List/List.svelte → List.svelte} +194 -133
- package/dist/components/{List/List.svelte.d.ts → List.svelte.d.ts} +9 -1
- package/dist/components/Menu.svelte +117 -0
- package/dist/components/Menu.svelte.d.ts +20 -0
- package/dist/components/NotesEditor.svelte +127 -0
- package/dist/components/NotesEditor.svelte.d.ts +17 -0
- package/dist/components/{NumberInput/NumberInput.svelte → NumberInput.svelte} +146 -105
- package/dist/components/{NumberInput/NumberInput.svelte.d.ts → NumberInput.svelte.d.ts} +11 -4
- package/dist/components/PasswordInput.svelte +52 -0
- package/dist/components/PasswordInput.svelte.d.ts +20 -0
- package/dist/components/Popup.svelte +140 -0
- package/dist/components/Popup.svelte.d.ts +31 -0
- package/dist/components/Query.svelte +284 -0
- package/dist/components/Query.svelte.d.ts +15 -0
- package/dist/components/{Rating/Rating.svelte → Rating.svelte} +19 -10
- package/dist/components/Rating.svelte.d.ts +13 -0
- package/dist/components/{SearchInput/SearchInput.svelte → SearchInput.svelte} +19 -8
- package/dist/components/{SearchInput/SearchInput.svelte.d.ts → SearchInput.svelte.d.ts} +9 -2
- package/dist/components/{Select/Select.svelte → Select.svelte} +48 -76
- package/dist/components/{Select/Select.svelte.d.ts → Select.svelte.d.ts} +11 -1
- package/dist/components/SideBarMenu/SideBarMenu.svelte +724 -0
- package/dist/components/SideBarMenu/SideBarMenu.svelte.d.ts +109 -0
- package/dist/components/SideBarMenu/index.d.ts +2 -0
- package/dist/components/SideBarMenu/index.js +1 -0
- package/dist/components/TabStrip/TabStrip.svelte +384 -0
- package/dist/components/TabStrip/TabStrip.svelte.d.ts +50 -0
- package/dist/components/TabStrip/index.d.ts +3 -0
- package/dist/components/TabStrip/index.js +2 -0
- package/dist/components/{TextArea/TextArea.svelte → TextArea.svelte} +143 -112
- package/dist/components/TextArea.svelte.d.ts +38 -0
- package/dist/components/ThemeSelector.svelte +81 -0
- package/dist/components/ThemeSelector.svelte.d.ts +10 -0
- package/dist/components/TimePicker.svelte +148 -0
- package/dist/components/TimePicker.svelte.d.ts +28 -0
- package/dist/components/TimeRangeInput.svelte +203 -0
- package/dist/components/TimeRangeInput.svelte.d.ts +29 -0
- package/dist/components/TimeSpinner.svelte +202 -0
- package/dist/components/TimeSpinner.svelte.d.ts +26 -0
- package/dist/components/Toast/Toaster.svelte +51 -0
- package/dist/components/Toast/Toaster.svelte.d.ts +12 -0
- package/dist/components/Toast/toast.svelte.d.ts +29 -0
- package/dist/components/Toast/toast.svelte.js +27 -0
- package/dist/components/Toggle.svelte +93 -0
- package/dist/components/Toggle.svelte.d.ts +15 -0
- package/dist/data/dataset.d.ts +42 -0
- package/dist/data/dataset.js +3 -0
- package/dist/data/index.d.ts +3 -0
- package/dist/data/index.js +3 -0
- package/dist/data/query/ast.d.ts +62 -0
- package/dist/data/query/ast.js +12 -0
- package/dist/data/query/index.d.ts +3 -0
- package/dist/data/query/index.js +3 -0
- package/dist/data/query/lexer.d.ts +33 -0
- package/dist/data/query/lexer.js +225 -0
- package/dist/data/query/parser.d.ts +11 -0
- package/dist/data/query/parser.js +252 -0
- package/dist/data/table/grid.svelte.d.ts +57 -0
- package/dist/data/table/grid.svelte.js +139 -0
- package/dist/data/table/index.d.ts +2 -0
- package/dist/data/table/index.js +2 -0
- package/dist/data/table/types.d.ts +79 -0
- package/dist/index.d.ts +48 -22
- package/dist/index.js +45 -21
- package/dist/positioning/anchored.d.ts +61 -0
- package/dist/positioning/anchored.js +122 -0
- package/dist/positioning/tooltip.d.ts +41 -0
- package/dist/positioning/tooltip.js +147 -0
- package/dist/theme.css +205 -0
- package/dist/types/breakpoints.d.ts +24 -0
- package/dist/types/breakpoints.js +13 -0
- package/dist/types/responsive.d.ts +32 -0
- package/dist/types/responsive.js +54 -0
- package/dist/types/sizes.d.ts +10 -3
- package/dist/types/time.d.ts +19 -0
- package/dist/types/time.js +10 -0
- package/dist/types/variants.d.ts +8 -1
- package/dist/types/variants.js +16 -1
- package/package.json +93 -63
- package/dist/components/Button/Button.svelte +0 -172
- package/dist/components/Button/Button.svelte.d.ts +0 -32
- package/dist/components/Button/index.d.ts +0 -1
- package/dist/components/Button/index.js +0 -1
- package/dist/components/Dialog/Dialog.svelte +0 -101
- package/dist/components/Dialog/Dialog.svelte.d.ts +0 -18
- package/dist/components/Dialog/index.d.ts +0 -1
- package/dist/components/Dialog/index.js +0 -1
- package/dist/components/ElementManager/ElementManager.svelte +0 -397
- package/dist/components/ElementManager/ElementManager.svelte.d.ts +0 -43
- package/dist/components/ElementManager/index.d.ts +0 -1
- package/dist/components/ElementManager/index.js +0 -1
- package/dist/components/EmailInput/EmailInput.svelte.d.ts +0 -14
- package/dist/components/EmailInput/index.d.ts +0 -1
- package/dist/components/EmailInput/index.js +0 -1
- package/dist/components/Icon/Icon.svelte +0 -74
- package/dist/components/Icon/Icon.svelte.d.ts +0 -13
- package/dist/components/Icon/index.d.ts +0 -1
- package/dist/components/Icon/index.js +0 -1
- package/dist/components/Input/Input.svelte +0 -268
- package/dist/components/Input/Input.svelte.d.ts +0 -18
- package/dist/components/Input/index.d.ts +0 -1
- package/dist/components/Input/index.js +0 -1
- package/dist/components/List/index.d.ts +0 -1
- package/dist/components/List/index.js +0 -1
- package/dist/components/ListItem/ListItem.svelte +0 -175
- package/dist/components/ListItem/ListItem.svelte.d.ts +0 -24
- package/dist/components/ListItem/index.d.ts +0 -2
- package/dist/components/ListItem/index.js +0 -2
- package/dist/components/ListView/ListView.svelte +0 -463
- package/dist/components/ListView/ListView.svelte.d.ts +0 -37
- package/dist/components/ListView/index.d.ts +0 -2
- package/dist/components/ListView/index.js +0 -2
- package/dist/components/NodeGraph/BaseNode.d.ts +0 -57
- package/dist/components/NodeGraph/BaseNode.js +0 -30
- package/dist/components/NodeGraph/Edge.svelte +0 -60
- package/dist/components/NodeGraph/Edge.svelte.d.ts +0 -16
- package/dist/components/NodeGraph/ExpressionEvaluator.d.ts +0 -82
- package/dist/components/NodeGraph/ExpressionEvaluator.js +0 -152
- package/dist/components/NodeGraph/Node.svelte +0 -100
- package/dist/components/NodeGraph/Node.svelte.d.ts +0 -10
- package/dist/components/NodeGraph/NodeGraph.svelte +0 -52
- package/dist/components/NodeGraph/NodeGraph.svelte.d.ts +0 -14
- package/dist/components/NodeGraph/NodeInstance.svelte.d.ts +0 -80
- package/dist/components/NodeGraph/NodeInstance.svelte.js +0 -241
- package/dist/components/NodeGraph/NodePort.svelte +0 -75
- package/dist/components/NodeGraph/NodePort.svelte.d.ts +0 -11
- package/dist/components/NodeGraph/decorators.d.ts +0 -81
- package/dist/components/NodeGraph/decorators.js +0 -148
- package/dist/components/NodeGraph/index.d.ts +0 -9
- package/dist/components/NodeGraph/index.js +0 -9
- package/dist/components/NodeGraph/types.d.ts +0 -94
- package/dist/components/NodeGraph/types.js +0 -33
- package/dist/components/NotesEditor/NotesEditor.svelte +0 -203
- package/dist/components/NotesEditor/NotesEditor.svelte.d.ts +0 -9
- package/dist/components/NotesEditor/index.d.ts +0 -1
- package/dist/components/NotesEditor/index.js +0 -1
- package/dist/components/NumberInput/index.d.ts +0 -1
- package/dist/components/NumberInput/index.js +0 -1
- package/dist/components/PasswordInput/PasswordInput.svelte +0 -41
- package/dist/components/PasswordInput/PasswordInput.svelte.d.ts +0 -13
- package/dist/components/PasswordInput/index.d.ts +0 -1
- package/dist/components/PasswordInput/index.js +0 -1
- package/dist/components/Popup/index.d.ts +0 -1
- package/dist/components/Popup/index.js +0 -1
- package/dist/components/Rating/Rating.svelte.d.ts +0 -7
- package/dist/components/Rating/index.d.ts +0 -1
- package/dist/components/Rating/index.js +0 -1
- package/dist/components/ScrollView/ScrollView.svelte +0 -285
- package/dist/components/ScrollView/ScrollView.svelte.d.ts +0 -19
- package/dist/components/ScrollView/index.d.ts +0 -1
- package/dist/components/ScrollView/index.js +0 -1
- package/dist/components/SearchInput/index.d.ts +0 -1
- package/dist/components/SearchInput/index.js +0 -1
- package/dist/components/Select/index.d.ts +0 -1
- package/dist/components/Select/index.js +0 -1
- package/dist/components/TextArea/TextArea.svelte.d.ts +0 -19
- package/dist/components/TextArea/index.d.ts +0 -1
- package/dist/components/TextArea/index.js +0 -1
- package/dist/components/ThemeSelector/ThemeSelector.svelte +0 -64
- package/dist/components/ThemeSelector/ThemeSelector.svelte.d.ts +0 -3
- package/dist/components/ThemeSelector/index.d.ts +0 -1
- package/dist/components/ThemeSelector/index.js +0 -1
- package/dist/components/TooltipHandler/TooltipHandler.svelte +0 -593
- package/dist/components/TooltipHandler/TooltipHandler.svelte.d.ts +0 -10
- package/dist/components/TooltipHandler/index.d.ts +0 -1
- package/dist/components/TooltipHandler/index.js +0 -1
- package/dist/styles/sizes.d.ts +0 -78
- package/dist/styles/sizes.js +0 -120
- package/dist/styles/variants.d.ts +0 -106
- package/dist/styles/variants.js +0 -194
- package/dist/types/ManagerTypes.d.ts +0 -42
- /package/dist/{types/ManagerTypes.js → data/table/types.js} +0 -0
|
@@ -0,0 +1,724 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
/**
|
|
3
|
+
* SideBarMenu — a vertical nav strip with a top-anchored group, an optional
|
|
4
|
+
* bottom-anchored group (pushed to the strip's bottom edge), and hover-open
|
|
5
|
+
* popouts for parent items.
|
|
6
|
+
*
|
|
7
|
+
* Driven by data — pass `topItems` / `bottomItems` arrays of {@link SideBarMenuItem}.
|
|
8
|
+
* Each item declares its own icon (Snippet), label, link, badge, and optional
|
|
9
|
+
* children. Children render in a top-layer Popup on hover or click.
|
|
10
|
+
*
|
|
11
|
+
* See `SideBarMenu.spec.md` for the full API + behavior contract.
|
|
12
|
+
*/
|
|
13
|
+
-->
|
|
14
|
+
<script lang="ts" module>
|
|
15
|
+
/**
|
|
16
|
+
* Visual / interaction mode at a given viewport tier.
|
|
17
|
+
*
|
|
18
|
+
* - `stepped` — `position: absolute`, slides in from the left,
|
|
19
|
+
* full-width. Parent items use click-to-step
|
|
20
|
+
* navigation (no hover popouts). Requires a
|
|
21
|
+
* hamburger trigger. Designed for phones.
|
|
22
|
+
* - `drawer` — `position: absolute`, slides in from the left,
|
|
23
|
+
* `--sbm-expanded-w` wide. Parent items show hover
|
|
24
|
+
* popouts to the right. Requires a hamburger.
|
|
25
|
+
* Designed for tablet portrait / phablet.
|
|
26
|
+
* - `icon` — `position: relative` (in flow), `--sbm-collapsed-w`
|
|
27
|
+
* wide, icon-only rows. Parent items show hover
|
|
28
|
+
* popouts. Designed for tablet landscape / narrow
|
|
29
|
+
* desktop.
|
|
30
|
+
* - `expanded` — `position: relative`, `--sbm-expanded-w` wide,
|
|
31
|
+
* icon + label rows. Parent items show hover popouts.
|
|
32
|
+
* Designed for wide desktop.
|
|
33
|
+
*/
|
|
34
|
+
export type SideBarMenuMode = "stepped" | "drawer" | "icon" | "expanded";
|
|
35
|
+
|
|
36
|
+
export type SideBarMenuBadgeVariant = "error" | "warning" | "info" | "success";
|
|
37
|
+
|
|
38
|
+
export type SideBarMenuBadge = {
|
|
39
|
+
/** Color of the indicator. */
|
|
40
|
+
variant: SideBarMenuBadgeVariant;
|
|
41
|
+
/**
|
|
42
|
+
* Optional numeric count. Present → filled pill with the number.
|
|
43
|
+
* Absent → bare icon (variant-coloured). Useful for "has-any" signals
|
|
44
|
+
* where the precise count isn't meaningful to the user.
|
|
45
|
+
*/
|
|
46
|
+
count?: number;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Icon component slot. Typed permissively because `unplugin-icons` emits
|
|
51
|
+
* its own callable shape (`typeof default`) that doesn't satisfy Svelte's
|
|
52
|
+
* strict `Component<…>` primitive in every consumer tsconfig. The runtime
|
|
53
|
+
* shape is always a Svelte component the renderer can `<Icon />` — TS just
|
|
54
|
+
* can't always prove it.
|
|
55
|
+
*/
|
|
56
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
57
|
+
export type SideBarMenuItemIcon = any;
|
|
58
|
+
|
|
59
|
+
export type SideBarMenuItem = {
|
|
60
|
+
/**
|
|
61
|
+
* Icon component constructor — same idiom as `MenuItem.icon`. Pass an
|
|
62
|
+
* imported MDI icon (e.g. `import IconActivity from '~icons/mdi/pulse'`)
|
|
63
|
+
* straight into the data array; no snippet wrapper needed.
|
|
64
|
+
*/
|
|
65
|
+
icon?: SideBarMenuItemIcon;
|
|
66
|
+
label: string;
|
|
67
|
+
/** href; absent when the item is a parent that only hosts children. */
|
|
68
|
+
link?: string;
|
|
69
|
+
badge?: SideBarMenuBadge;
|
|
70
|
+
/** v1 renders only the first level; deeper levels emit a DEV warning. */
|
|
71
|
+
children?: SideBarMenuItem[];
|
|
72
|
+
};
|
|
73
|
+
</script>
|
|
74
|
+
|
|
75
|
+
<script lang="ts">
|
|
76
|
+
import { DEV } from "esm-env";
|
|
77
|
+
import Popup from "../Popup.svelte";
|
|
78
|
+
import { tooltip } from "../../positioning/tooltip.js";
|
|
79
|
+
import IconAlert from "~icons/mdi/alert";
|
|
80
|
+
import IconCloseOctagon from "~icons/mdi/close-octagon";
|
|
81
|
+
import IconInformation from "~icons/mdi/information";
|
|
82
|
+
import IconCheckCircle from "~icons/mdi/check-circle";
|
|
83
|
+
import IconMenu from "~icons/mdi/menu";
|
|
84
|
+
import IconClose from "~icons/mdi/close";
|
|
85
|
+
import IconChevronRight from "~icons/mdi/chevron-right";
|
|
86
|
+
import type { Size } from "../../types/sizes.js";
|
|
87
|
+
import { BREAKPOINT_PX, type Breakpoint } from "../../types/breakpoints.js";
|
|
88
|
+
|
|
89
|
+
type ModesMap = Partial<Record<Breakpoint, SideBarMenuMode>>;
|
|
90
|
+
|
|
91
|
+
let {
|
|
92
|
+
topItems = [],
|
|
93
|
+
bottomItems = [],
|
|
94
|
+
currentPath,
|
|
95
|
+
size = "md",
|
|
96
|
+
modes,
|
|
97
|
+
collapsedAt = "lg",
|
|
98
|
+
collapsedWidth = 48,
|
|
99
|
+
expandedWidth = 200,
|
|
100
|
+
open = $bindable(false),
|
|
101
|
+
stepLevel = $bindable(0),
|
|
102
|
+
hamburger = true,
|
|
103
|
+
class: additionalClass = "",
|
|
104
|
+
}: {
|
|
105
|
+
topItems?: SideBarMenuItem[];
|
|
106
|
+
bottomItems?: SideBarMenuItem[];
|
|
107
|
+
currentPath: string;
|
|
108
|
+
size?: Size;
|
|
109
|
+
/**
|
|
110
|
+
* Per-tier visual mode. Each key in this map sets the mode at and
|
|
111
|
+
* above that breakpoint. The active mode at any viewport is the
|
|
112
|
+
* mode for the highest matching tier.
|
|
113
|
+
*
|
|
114
|
+
* When omitted, defaults to
|
|
115
|
+
* `{ base: 'stepped', '2xs': 'drawer', xs: 'icon', [collapsedAt]: 'expanded' }`
|
|
116
|
+
* so the prop is opt-in for consumers happy with the standard
|
|
117
|
+
* phone→tablet→desktop progression.
|
|
118
|
+
*/
|
|
119
|
+
modes?: ModesMap;
|
|
120
|
+
/**
|
|
121
|
+
* Legacy shorthand: which tier the strip expands to icon + label
|
|
122
|
+
* at when `modes` isn't supplied. Equivalent to overriding only
|
|
123
|
+
* the `expanded` slot in the default `modes` map. Ignored when
|
|
124
|
+
* `modes` is set explicitly. Prefer passing `modes` directly.
|
|
125
|
+
*/
|
|
126
|
+
collapsedAt?: Exclude<Breakpoint, "base" | "4xs" | "3xs" | "2xs" | "xs">;
|
|
127
|
+
/** Pixel width of the strip in `icon` mode. */
|
|
128
|
+
collapsedWidth?: number;
|
|
129
|
+
/** Pixel width of the strip in `expanded` mode and the slide-in drawer when open. */
|
|
130
|
+
expandedWidth?: number;
|
|
131
|
+
/**
|
|
132
|
+
* Drawer open state when the active mode is `drawer` or `stepped`.
|
|
133
|
+
* Bind from the consumer to drive it from your own header button
|
|
134
|
+
* (set `hamburger={false}` to suppress the built-in trigger).
|
|
135
|
+
* Ignored in `icon` and `expanded` modes (strip is always in-layout).
|
|
136
|
+
*/
|
|
137
|
+
open?: boolean;
|
|
138
|
+
/**
|
|
139
|
+
* Step-navigation level inside the `stepped`-mode drawer.
|
|
140
|
+
* `0` = top-level. `1` = inside a parent item's children list.
|
|
141
|
+
* Bind from the consumer so a header button can read it (e.g.
|
|
142
|
+
* to flip X → ← icon) and write it (set to `0` to navigate back).
|
|
143
|
+
*/
|
|
144
|
+
stepLevel?: number;
|
|
145
|
+
/**
|
|
146
|
+
* Render the built-in fixed-position hamburger button when the
|
|
147
|
+
* active mode is `drawer` or `stepped`. Set to `false` if the
|
|
148
|
+
* consumer renders its own trigger (typically inside a header)
|
|
149
|
+
* and binds to `open`.
|
|
150
|
+
*/
|
|
151
|
+
hamburger?: boolean;
|
|
152
|
+
class?: string;
|
|
153
|
+
} = $props();
|
|
154
|
+
|
|
155
|
+
// Default mode map — replicates the pre-modes behavior so old
|
|
156
|
+
// consumers continue working unchanged. `collapsedAt` plugs into the
|
|
157
|
+
// `expanded` slot so the legacy prop still tunes where labels appear.
|
|
158
|
+
const effectiveModes = $derived<ModesMap>(
|
|
159
|
+
modes ?? {
|
|
160
|
+
base: "stepped",
|
|
161
|
+
"2xs": "drawer",
|
|
162
|
+
xs: "icon",
|
|
163
|
+
[collapsedAt]: "expanded",
|
|
164
|
+
},
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
function resolveMode(map: ModesMap, viewportPx: number): SideBarMenuMode {
|
|
168
|
+
const tiers = (Object.keys(map) as Breakpoint[]).sort(
|
|
169
|
+
(a, b) => BREAKPOINT_PX[a] - BREAKPOINT_PX[b],
|
|
170
|
+
);
|
|
171
|
+
let active: SideBarMenuMode = "expanded";
|
|
172
|
+
for (const tier of tiers) {
|
|
173
|
+
if (viewportPx >= BREAKPOINT_PX[tier]) {
|
|
174
|
+
const m = map[tier];
|
|
175
|
+
if (m) active = m;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return active;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// SSR-safe default — the effect below corrects on mount.
|
|
182
|
+
let currentMode = $state<SideBarMenuMode>("expanded");
|
|
183
|
+
|
|
184
|
+
$effect(() => {
|
|
185
|
+
const map = effectiveModes;
|
|
186
|
+
function sync() {
|
|
187
|
+
currentMode = resolveMode(map, window.innerWidth);
|
|
188
|
+
}
|
|
189
|
+
// One matchMedia listener per tier in the map. Cheaper than a
|
|
190
|
+
// general resize handler — only fires when the active tier
|
|
191
|
+
// actually flips.
|
|
192
|
+
const tiers = (Object.keys(map) as Breakpoint[]).filter((t) => t !== "base");
|
|
193
|
+
const queries = tiers.map((t) => window.matchMedia(`(min-width: ${BREAKPOINT_PX[t]}px)`));
|
|
194
|
+
sync();
|
|
195
|
+
queries.forEach((q) => q.addEventListener("change", sync));
|
|
196
|
+
return () => queries.forEach((q) => q.removeEventListener("change", sync));
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
const isStepped = $derived(currentMode === "stepped");
|
|
200
|
+
const isDrawer = $derived(currentMode === "drawer" || currentMode === "stepped");
|
|
201
|
+
const labelsVisible = $derived(currentMode !== "icon");
|
|
202
|
+
|
|
203
|
+
// ── DEV depth warning ───────────────────────────────────────────
|
|
204
|
+
if (DEV) {
|
|
205
|
+
const walk = (items: SideBarMenuItem[], depth: number) => {
|
|
206
|
+
for (const it of items) {
|
|
207
|
+
if (it.children?.length) {
|
|
208
|
+
if (depth >= 1) {
|
|
209
|
+
console.warn(
|
|
210
|
+
'SideBarMenu: nesting deeper than 1 level is not yet rendered; flattening grandchildren of "' +
|
|
211
|
+
it.label +
|
|
212
|
+
'"',
|
|
213
|
+
);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
walk(it.children, depth + 1);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
walk(topItems, 0);
|
|
221
|
+
walk(bottomItems, 0);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ── Size axis ───────────────────────────────────────────────────
|
|
225
|
+
// All sub-element sizes derive from the icon size:
|
|
226
|
+
// - Badge icon ≈ icon × 0.5 (warning/error/info/success glyph)
|
|
227
|
+
// - Count pill ≈ icon × 0.6 (a touch bigger to leave room for digits)
|
|
228
|
+
// - Offsets = (boxSize − iconSize) / 2 − badgeSize / 2 so the
|
|
229
|
+
// badge centers on the icon's top-right corner
|
|
230
|
+
// - Chevron scales with the main icon (sm 14 / md 16 / lg 20)
|
|
231
|
+
type SizeStyles = {
|
|
232
|
+
row: string;
|
|
233
|
+
iconBox: string;
|
|
234
|
+
iconSvg: string;
|
|
235
|
+
text: string;
|
|
236
|
+
badgeIcon: string; // [&_svg]:w-N [&_svg]:h-N for warning/error glyph
|
|
237
|
+
badgeIconOffset: string; // absolute top-[N] right-[N] for the icon badge
|
|
238
|
+
pill: string; // min-w + h literal for the count pill
|
|
239
|
+
pillText: string; // text-[Npx] for the count digits
|
|
240
|
+
pillOffset: string; // absolute top-[N] right-[N] for the count pill
|
|
241
|
+
chevronBox: string; // wrapper around the parent-row chevron
|
|
242
|
+
chevronSvg: string; // chevron SVG sizing
|
|
243
|
+
};
|
|
244
|
+
const sizeMap: Record<Size, SizeStyles> = {
|
|
245
|
+
sm: {
|
|
246
|
+
row: "h-10",
|
|
247
|
+
iconBox: "w-10 h-10",
|
|
248
|
+
iconSvg: "[&_svg]:w-5 [&_svg]:h-5",
|
|
249
|
+
text: "text-xs",
|
|
250
|
+
// Badge dot scales roughly 1/3 of the main icon: solid circle
|
|
251
|
+
// that occludes the corner. Span is sized directly (not via
|
|
252
|
+
// descendant svg selector) so it's exactly as specified.
|
|
253
|
+
badgeIcon: "w-1.5 h-1.5",
|
|
254
|
+
badgeIconOffset: "top-[7px] right-[7px]",
|
|
255
|
+
pill: "min-w-[12px] h-[12px]",
|
|
256
|
+
pillText: "text-[8px]",
|
|
257
|
+
pillOffset: "top-[4px] right-[4px]",
|
|
258
|
+
chevronBox: "w-5 h-5",
|
|
259
|
+
chevronSvg: "[&_svg]:w-3.5 [&_svg]:h-3.5",
|
|
260
|
+
},
|
|
261
|
+
md: {
|
|
262
|
+
row: "h-12",
|
|
263
|
+
iconBox: "w-12 h-12",
|
|
264
|
+
iconSvg: "[&_svg]:w-6 [&_svg]:h-6",
|
|
265
|
+
text: "text-sm",
|
|
266
|
+
badgeIcon: "w-2 h-2",
|
|
267
|
+
badgeIconOffset: "top-[8px] right-[8px]",
|
|
268
|
+
pill: "min-w-[14px] h-[14px]",
|
|
269
|
+
pillText: "text-[9px]",
|
|
270
|
+
pillOffset: "top-[5px] right-[5px]",
|
|
271
|
+
chevronBox: "w-6 h-6",
|
|
272
|
+
chevronSvg: "[&_svg]:w-4 [&_svg]:h-4",
|
|
273
|
+
},
|
|
274
|
+
lg: {
|
|
275
|
+
row: "h-14",
|
|
276
|
+
iconBox: "w-14 h-14",
|
|
277
|
+
iconSvg: "[&_svg]:w-7 [&_svg]:h-7",
|
|
278
|
+
text: "text-base",
|
|
279
|
+
badgeIcon: "w-2.5 h-2.5",
|
|
280
|
+
badgeIconOffset: "top-[9px] right-[9px]",
|
|
281
|
+
pill: "min-w-[16px] h-[16px]",
|
|
282
|
+
pillText: "text-[10px]",
|
|
283
|
+
pillOffset: "top-[6px] right-[6px]",
|
|
284
|
+
chevronBox: "w-7 h-7",
|
|
285
|
+
chevronSvg: "[&_svg]:w-5 [&_svg]:h-5",
|
|
286
|
+
},
|
|
287
|
+
};
|
|
288
|
+
const sz = $derived(sizeMap[size]);
|
|
289
|
+
|
|
290
|
+
// ── Mobile drawer state ─────────────────────────────────────────
|
|
291
|
+
// Below `xs` the strip collapses to `width: 0` and either the built-in
|
|
292
|
+
// hamburger (default) or a consumer-provided trigger toggles a slide-in
|
|
293
|
+
// drawer. At `xs+` the strip is always visible in-layout and `open` is
|
|
294
|
+
// irrelevant. At `<2xs` the drawer is full-width and parent items use
|
|
295
|
+
// click-to-step navigation instead of hover popouts.
|
|
296
|
+
function closeDrawer() {
|
|
297
|
+
open = false;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Reset every transient drawer state — used when a leaf navigates,
|
|
301
|
+
// so the next time the user opens the drawer they're back at the
|
|
302
|
+
// top of the menu (not still inside a stepped parent's children) and
|
|
303
|
+
// no popout is open.
|
|
304
|
+
function closeAfterNav() {
|
|
305
|
+
open = false;
|
|
306
|
+
stepLevel = 0;
|
|
307
|
+
openId = null;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// The parent whose children the drawer is currently displaying (when
|
|
311
|
+
// `stepLevel === 1`). Reset to null whenever stepLevel returns to 0
|
|
312
|
+
// or the drawer closes — the next step starts fresh from the top.
|
|
313
|
+
let currentParent = $state<SideBarMenuItem | null>(null);
|
|
314
|
+
|
|
315
|
+
$effect(() => {
|
|
316
|
+
if (stepLevel === 0) currentParent = null;
|
|
317
|
+
});
|
|
318
|
+
$effect(() => {
|
|
319
|
+
if (!open) {
|
|
320
|
+
stepLevel = 0;
|
|
321
|
+
currentParent = null;
|
|
322
|
+
}
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
function stepIntoChildren(item: SideBarMenuItem) {
|
|
326
|
+
currentParent = item;
|
|
327
|
+
stepLevel = 1;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Label / chevron visibility are now driven by the active mode
|
|
331
|
+
// instead of by responsive class strings — the matchMedia effect
|
|
332
|
+
// above keeps `currentMode` (and therefore `labelsVisible`) in sync
|
|
333
|
+
// with the viewport. The chevron span needs `inline-flex` (not
|
|
334
|
+
// `inline`) so its SVG stays centered; otherwise the same logic.
|
|
335
|
+
const labelClass = $derived(labelsVisible ? "inline" : "hidden");
|
|
336
|
+
const chevronVisibility = $derived(labelsVisible ? "inline-flex" : "hidden");
|
|
337
|
+
// The corresponding "label is currently visible" check used to disable
|
|
338
|
+
// tooltips at the expanded breakpoint. We don't have direct access to
|
|
339
|
+
// the media query here without an effect, so we always pass the tooltip
|
|
340
|
+
// the label text and let the tooltip's own `disabled` option be flipped
|
|
341
|
+
// off by the popout-open state; when the label is on-screen the user
|
|
342
|
+
// has no reason to hover for it anyway.
|
|
343
|
+
|
|
344
|
+
// ── Active state ────────────────────────────────────────────────
|
|
345
|
+
function isItemActive(item: SideBarMenuItem): boolean {
|
|
346
|
+
if (item.link && currentPath.startsWith(item.link)) return true;
|
|
347
|
+
if (item.children?.length) return item.children.some(isItemActive);
|
|
348
|
+
return false;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// ── Hover/click open state ──────────────────────────────────────
|
|
352
|
+
type ItemId = `top:${number}` | `bottom:${number}`;
|
|
353
|
+
|
|
354
|
+
let openId = $state<ItemId | null>(null);
|
|
355
|
+
let closeTimer: ReturnType<typeof setTimeout> | null = null;
|
|
356
|
+
const CLOSE_GRACE_MS = 150;
|
|
357
|
+
|
|
358
|
+
function cancelClose() {
|
|
359
|
+
if (closeTimer !== null) {
|
|
360
|
+
clearTimeout(closeTimer);
|
|
361
|
+
closeTimer = null;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
function scheduleClose() {
|
|
365
|
+
cancelClose();
|
|
366
|
+
closeTimer = setTimeout(() => {
|
|
367
|
+
openId = null;
|
|
368
|
+
closeTimer = null;
|
|
369
|
+
}, CLOSE_GRACE_MS);
|
|
370
|
+
}
|
|
371
|
+
function openOnHover(id: ItemId) {
|
|
372
|
+
cancelClose();
|
|
373
|
+
if (openId !== id) openId = id;
|
|
374
|
+
}
|
|
375
|
+
function toggleOnClick(id: ItemId) {
|
|
376
|
+
cancelClose();
|
|
377
|
+
openId = openId === id ? null : id;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// ── Refs ────────────────────────────────────────────────────────
|
|
381
|
+
let asideRef = $state<HTMLElement | undefined>();
|
|
382
|
+
// One ref per parent trigger. Keyed by ItemId so Popup can anchor.
|
|
383
|
+
const triggerRefs = $state<Record<string, HTMLElement | undefined>>({});
|
|
384
|
+
|
|
385
|
+
// Callback-ref action — `bind:this` can't take a {get, set} pair and can't
|
|
386
|
+
// take a ternary expression, so we use a tiny action to wire each row's
|
|
387
|
+
// DOM node into the triggerRefs map by id. No-op when id is undefined
|
|
388
|
+
// (popout-child rows pass no id since they're never anchors themselves).
|
|
389
|
+
function refIntoMap(node: HTMLElement, id: ItemId | undefined) {
|
|
390
|
+
if (id) triggerRefs[id] = node;
|
|
391
|
+
return {
|
|
392
|
+
update(newId: ItemId | undefined) {
|
|
393
|
+
if (newId) triggerRefs[newId] = node;
|
|
394
|
+
},
|
|
395
|
+
destroy() {
|
|
396
|
+
if (id) triggerRefs[id] = undefined;
|
|
397
|
+
},
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Spans rule — see SideBarMenu.spec.md → Popout Positioning.
|
|
402
|
+
function spansEdge(group: "top" | "bottom", idx: number, total: number, count: number) {
|
|
403
|
+
// positionFromEnd: how many rows from the anchored edge inclusive.
|
|
404
|
+
const positionFromEnd = group === "bottom" ? total - idx : idx + 1;
|
|
405
|
+
return count >= positionFromEnd;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function geometryFor(
|
|
409
|
+
id: ItemId,
|
|
410
|
+
group: "top" | "bottom",
|
|
411
|
+
idx: number,
|
|
412
|
+
total: number,
|
|
413
|
+
count: number,
|
|
414
|
+
): {
|
|
415
|
+
anchor: HTMLElement | undefined;
|
|
416
|
+
align: "start" | "end";
|
|
417
|
+
borderClass: string;
|
|
418
|
+
} {
|
|
419
|
+
const spans = spansEdge(group, idx, total, count);
|
|
420
|
+
// Border policy:
|
|
421
|
+
// - Right border is always needed (popout meets free content
|
|
422
|
+
// area on the right).
|
|
423
|
+
// - Top/bottom borders are needed only on edges that DON'T
|
|
424
|
+
// coincide with the sidebar's edge. When `spans` is true,
|
|
425
|
+
// the popout's edge on the anchored side is flush with the
|
|
426
|
+
// sidebar's top/bottom — that edge gets no border. The
|
|
427
|
+
// opposite edge always extends into the sidebar's middle
|
|
428
|
+
// vertical range, so it always gets a border.
|
|
429
|
+
// - When `spans` is false the popout sits at the trigger row
|
|
430
|
+
// level; both top and bottom edges are mid-sidebar and need
|
|
431
|
+
// borders.
|
|
432
|
+
// - Left border never — the sidebar's own `border-right`
|
|
433
|
+
// covers that seam.
|
|
434
|
+
// NOTE: very tall popouts that would exceed the sidebar's
|
|
435
|
+
// height get clamped to the viewport by the `anchored` engine
|
|
436
|
+
// boundary. That case also needs in-popout scroll (TODO) and
|
|
437
|
+
// the border rule may need to account for viewport-edge cases.
|
|
438
|
+
let borderClass = "border-r";
|
|
439
|
+
if (group === "bottom") {
|
|
440
|
+
borderClass += spans ? " border-t" : " border-t border-b";
|
|
441
|
+
} else {
|
|
442
|
+
borderClass += spans ? " border-b" : " border-t border-b";
|
|
443
|
+
}
|
|
444
|
+
if (group === "bottom") {
|
|
445
|
+
return spans
|
|
446
|
+
? { anchor: asideRef, align: "end", borderClass }
|
|
447
|
+
: { anchor: triggerRefs[id], align: "start", borderClass };
|
|
448
|
+
}
|
|
449
|
+
return spans
|
|
450
|
+
? { anchor: asideRef, align: "start", borderClass }
|
|
451
|
+
: { anchor: triggerRefs[id], align: "end", borderClass };
|
|
452
|
+
}
|
|
453
|
+
</script>
|
|
454
|
+
|
|
455
|
+
<!-- Badge slot — count omitted = solid colored dot covering the icon's
|
|
456
|
+
top-right corner; count present = solid pill with the number. Both
|
|
457
|
+
have an opaque background that occludes the main icon underneath so
|
|
458
|
+
the badge always reads as "on top of" the icon. All sizing /
|
|
459
|
+
positioning comes from `sizeMap` and scales with the `size` prop. -->
|
|
460
|
+
{#snippet badge(b: SideBarMenuBadge)}
|
|
461
|
+
{@const colorVar = `var(--ui-color-${b.variant})`}
|
|
462
|
+
{#if b.count === undefined}
|
|
463
|
+
<span
|
|
464
|
+
class="absolute {sz.badgeIconOffset} inline-block rounded-full {sz.badgeIcon}"
|
|
465
|
+
style="background-color: {colorVar};"
|
|
466
|
+
aria-hidden="true"
|
|
467
|
+
></span>
|
|
468
|
+
{:else}
|
|
469
|
+
<span
|
|
470
|
+
class="absolute {sz.pillOffset} inline-flex items-center justify-center rounded-full px-1 {sz.pill} {sz.pillText} font-semibold leading-none"
|
|
471
|
+
style="background-color: {colorVar}; color: white;"
|
|
472
|
+
aria-hidden="true"
|
|
473
|
+
>
|
|
474
|
+
{b.count}
|
|
475
|
+
</span>
|
|
476
|
+
{/if}
|
|
477
|
+
{/snippet}
|
|
478
|
+
|
|
479
|
+
<!-- Inner content of a row — icon (+ optional badge), label (truncated
|
|
480
|
+
when wider than the row), and a `>` chevron on parent items so
|
|
481
|
+
the user can see at a glance which items host children. The
|
|
482
|
+
`forceLabel` flag overrides the strip's responsive label-visibility
|
|
483
|
+
rule — popout children and stepped sub-view children always show
|
|
484
|
+
their text labels regardless of the outer strip's collapsed state. -->
|
|
485
|
+
{#snippet rowBody(item: SideBarMenuItem, isParent: boolean, forceLabel: boolean, isOpen: boolean)}
|
|
486
|
+
{@const labelClassResolved = forceLabel ? "inline" : labelClass}
|
|
487
|
+
{@const chevronClass = forceLabel ? "inline-flex" : chevronVisibility}
|
|
488
|
+
<span class="relative inline-flex shrink-0 items-center justify-center {sz.iconBox} {sz.iconSvg}">
|
|
489
|
+
{#if item.icon}{@const Icon = item.icon}<Icon />{/if}
|
|
490
|
+
{#if item.badge}{@render badge(item.badge)}{/if}
|
|
491
|
+
</span>
|
|
492
|
+
<!-- `flex-1 min-w-0 truncate` makes the label take the remaining
|
|
493
|
+
row width AND lets it shrink below its content size, which is
|
|
494
|
+
what `truncate` (overflow-hidden + text-ellipsis) needs to
|
|
495
|
+
actually clip overflow. Without `min-w-0` the label refuses
|
|
496
|
+
to shrink past its natural width and the row overflows. -->
|
|
497
|
+
<span class="flex-1 min-w-0 truncate {labelClassResolved} {sz.text}">{item.label}</span>
|
|
498
|
+
{#if isParent}
|
|
499
|
+
<!-- Chevron — visible only when the label is visible (no point
|
|
500
|
+
dangling a `>` next to an icon-only icon). Rotates 180° to
|
|
501
|
+
`<` whenever the parent is open OR being hovered, with a
|
|
502
|
+
`transition-transform` so it eases in/out. Uses
|
|
503
|
+
`chevronVisibility` (not `labelClassResolved`) so the span
|
|
504
|
+
stays `inline-flex` and the SVG centers vertically. -->
|
|
505
|
+
<span
|
|
506
|
+
class="shrink-0 items-center justify-center opacity-60 {sz.chevronBox} {sz.chevronSvg} {chevronClass}"
|
|
507
|
+
>
|
|
508
|
+
<IconChevronRight />
|
|
509
|
+
</span>
|
|
510
|
+
{/if}
|
|
511
|
+
{/snippet}
|
|
512
|
+
|
|
513
|
+
<!-- Single row template — used by topItems / bottomItems and the popout. -->
|
|
514
|
+
{#snippet row(item: SideBarMenuItem, opts: { id?: ItemId; isPopoutChild?: boolean })}
|
|
515
|
+
{@const active = isItemActive(item)}
|
|
516
|
+
{@const hasChildren = (item.children?.length ?? 0) > 0}
|
|
517
|
+
{@const isParent = hasChildren && !opts.isPopoutChild}
|
|
518
|
+
{@const hasLink = !!item.link}
|
|
519
|
+
{@const isOpen = !!opts.id && openId === opts.id}
|
|
520
|
+
{@const rowClass = `group flex items-center w-full ${sz.row} lg:pr-3 transition-colors cursor-pointer ${active ? "[background-color:var(--ui-color-primary)] text-white" : isOpen ? "[background-color:color-mix(in_srgb,var(--ui-color-text)_10%,transparent)]" : "hover:[background-color:color-mix(in_srgb,var(--ui-color-text)_10%,transparent)]"}`}
|
|
521
|
+
{#if hasLink}
|
|
522
|
+
<a
|
|
523
|
+
href={item.link}
|
|
524
|
+
use:refIntoMap={opts.id}
|
|
525
|
+
class={rowClass}
|
|
526
|
+
aria-current={active ? "page" : undefined}
|
|
527
|
+
use:tooltip={{
|
|
528
|
+
text: item.label,
|
|
529
|
+
side: "right",
|
|
530
|
+
disabled: isParent && opts.id ? openId === opts.id : false,
|
|
531
|
+
}}
|
|
532
|
+
onmouseenter={isParent && opts.id && !isStepped ? () => openOnHover(opts.id!) : undefined}
|
|
533
|
+
onmouseleave={isParent && opts.id && !isStepped ? scheduleClose : undefined}
|
|
534
|
+
onclick={(e) => {
|
|
535
|
+
// Power-user passthrough: Shift/Cmd/Ctrl click opens in
|
|
536
|
+
// a new tab — don't mutate UI state in that case.
|
|
537
|
+
if (e.shiftKey || e.metaKey || e.ctrlKey) return;
|
|
538
|
+
if (isParent && opts.id) {
|
|
539
|
+
if (isStepped) {
|
|
540
|
+
// At <2xs: tap parent → step into its children.
|
|
541
|
+
// Cancel default navigation so the click can't
|
|
542
|
+
// follow the parent's href out from under us.
|
|
543
|
+
e.preventDefault();
|
|
544
|
+
stepIntoChildren(item);
|
|
545
|
+
} else {
|
|
546
|
+
toggleOnClick(opts.id);
|
|
547
|
+
}
|
|
548
|
+
} else {
|
|
549
|
+
// Leaf or popout-child navigation. Reset the drawer
|
|
550
|
+
// so the next open starts at the top, the stepped
|
|
551
|
+
// sub-view doesn't linger, and any open popout closes.
|
|
552
|
+
closeAfterNav();
|
|
553
|
+
}
|
|
554
|
+
}}
|
|
555
|
+
>
|
|
556
|
+
{@render rowBody(item, isParent, !!opts.isPopoutChild, isOpen)}
|
|
557
|
+
</a>
|
|
558
|
+
{:else if isParent}
|
|
559
|
+
<button
|
|
560
|
+
type="button"
|
|
561
|
+
use:refIntoMap={opts.id}
|
|
562
|
+
class="{rowClass} text-left"
|
|
563
|
+
aria-haspopup="menu"
|
|
564
|
+
aria-expanded={openId === opts.id}
|
|
565
|
+
use:tooltip={{ text: item.label, side: "right", disabled: openId === opts.id }}
|
|
566
|
+
onmouseenter={!isStepped ? () => openOnHover(opts.id!) : undefined}
|
|
567
|
+
onmouseleave={!isStepped ? scheduleClose : undefined}
|
|
568
|
+
onclick={() => (isStepped ? stepIntoChildren(item) : toggleOnClick(opts.id!))}
|
|
569
|
+
>
|
|
570
|
+
{@render rowBody(item, isParent, !!opts.isPopoutChild, isOpen)}
|
|
571
|
+
</button>
|
|
572
|
+
{:else}
|
|
573
|
+
<!-- Malformed item: no link, no children. Render visibly so the
|
|
574
|
+
consumer notices, but with disabled-style cursor + opacity. -->
|
|
575
|
+
<span class="{rowClass} opacity-40 cursor-not-allowed" aria-disabled="true">
|
|
576
|
+
{@render rowBody(item, false, !!opts.isPopoutChild, false)}
|
|
577
|
+
</span>
|
|
578
|
+
{/if}
|
|
579
|
+
{/snippet}
|
|
580
|
+
|
|
581
|
+
<!-- Popout panel — fixed 200px (--sbm-expanded-w), edge-to-edge rows,
|
|
582
|
+
mirrors main strip styling. `borderClass` is computed per popout
|
|
583
|
+
by `geometryFor` so the visible edges get borders and the edges
|
|
584
|
+
that coincide with the sidebar's top/bottom don't. -->
|
|
585
|
+
{#snippet popout(item: SideBarMenuItem, borderClass: string)}
|
|
586
|
+
<!-- svelte-ignore a11y_interactive_supports_focus -->
|
|
587
|
+
<div
|
|
588
|
+
role="menu"
|
|
589
|
+
tabindex="-1"
|
|
590
|
+
class="w-[var(--sbm-expanded-w)] overflow-hidden shadow-lg [background-color:var(--ui-color-background)] [color:var(--ui-color-text)] [border-color:color-mix(in_srgb,var(--ui-color-text)_15%,transparent)] {borderClass}"
|
|
591
|
+
onmouseenter={cancelClose}
|
|
592
|
+
onmouseleave={scheduleClose}
|
|
593
|
+
>
|
|
594
|
+
<ul class="flex flex-col">
|
|
595
|
+
{#each item.children ?? [] as child (child.label)}
|
|
596
|
+
<li class="list-none" role="none">
|
|
597
|
+
{@render row(child, { isPopoutChild: true })}
|
|
598
|
+
</li>
|
|
599
|
+
{/each}
|
|
600
|
+
</ul>
|
|
601
|
+
</div>
|
|
602
|
+
{/snippet}
|
|
603
|
+
|
|
604
|
+
{#if hamburger && isDrawer}
|
|
605
|
+
<!-- Built-in hamburger — fixed top-left. Only rendered when the
|
|
606
|
+
active mode is `drawer` or `stepped`. Set `hamburger={false}`
|
|
607
|
+
to suppress this when the consumer provides its own trigger. -->
|
|
608
|
+
<button
|
|
609
|
+
type="button"
|
|
610
|
+
class="fixed top-3 left-3 z-[80] inline-flex w-10 h-10 items-center justify-center cursor-pointer [background-color:var(--ui-color-background)] border [border-color:color-mix(in_srgb,var(--ui-color-text)_15%,transparent)] shadow-md [&_svg]:w-5 [&_svg]:h-5"
|
|
611
|
+
aria-label={open ? "Close menu" : "Open menu"}
|
|
612
|
+
aria-expanded={open}
|
|
613
|
+
onclick={() => (open = !open)}
|
|
614
|
+
>
|
|
615
|
+
{#if open}<IconClose />{:else}<IconMenu />{/if}
|
|
616
|
+
</button>
|
|
617
|
+
{/if}
|
|
618
|
+
|
|
619
|
+
<!-- Backdrop for the mobile drawer. Click anywhere on it to close.
|
|
620
|
+
Only rendered when the active mode is `drawer` or `stepped` AND
|
|
621
|
+
the drawer is open. -->
|
|
622
|
+
{#if isDrawer && open}
|
|
623
|
+
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
|
624
|
+
<div
|
|
625
|
+
class="absolute inset-0 z-[70] [background-color:color-mix(in_srgb,black_30%,transparent)]"
|
|
626
|
+
onclick={closeDrawer}
|
|
627
|
+
></div>
|
|
628
|
+
{/if}
|
|
629
|
+
|
|
630
|
+
<aside
|
|
631
|
+
bind:this={asideRef}
|
|
632
|
+
style="--sbm-collapsed-w: {collapsedWidth}px; --sbm-expanded-w: {expandedWidth}px;"
|
|
633
|
+
class="
|
|
634
|
+
flex flex-col z-[60] overflow-hidden
|
|
635
|
+
[background-color:var(--ui-color-background)]
|
|
636
|
+
[color:var(--ui-color-text)]
|
|
637
|
+
[border-right:1px_solid_color-mix(in_srgb,var(--ui-color-text)_15%,transparent)]
|
|
638
|
+
{currentMode === 'stepped'
|
|
639
|
+
? `absolute inset-y-0 left-0 w-full z-[75] transition-transform duration-200 ${open ? 'translate-x-0' : '-translate-x-full'}`
|
|
640
|
+
: currentMode === 'drawer'
|
|
641
|
+
? `absolute inset-y-0 left-0 w-[var(--sbm-expanded-w)] z-[75] transition-transform duration-200 ${open ? 'translate-x-0' : '-translate-x-full'}`
|
|
642
|
+
: currentMode === 'icon'
|
|
643
|
+
? 'relative w-[var(--sbm-collapsed-w)]'
|
|
644
|
+
: 'relative w-[var(--sbm-expanded-w)]'}
|
|
645
|
+
{additionalClass}
|
|
646
|
+
"
|
|
647
|
+
>
|
|
648
|
+
{#if isStepped && stepLevel === 1 && currentParent}
|
|
649
|
+
<!-- Stepped sub-view (<2xs only): show just the children of the
|
|
650
|
+
parent the user tapped. The consumer's hamburger button is
|
|
651
|
+
expected to flip to a `←` icon and set `stepLevel = 0` to
|
|
652
|
+
return here — there is no in-drawer back row by design so
|
|
653
|
+
the back affordance stays in a single, consistent spot. -->
|
|
654
|
+
<ul class="flex flex-col">
|
|
655
|
+
{#each currentParent.children ?? [] as child (child.label)}
|
|
656
|
+
<li class="list-none">
|
|
657
|
+
{@render row(child, { isPopoutChild: true })}
|
|
658
|
+
</li>
|
|
659
|
+
{/each}
|
|
660
|
+
</ul>
|
|
661
|
+
{:else}
|
|
662
|
+
{#if topItems.length > 0}
|
|
663
|
+
<ul class="flex flex-col">
|
|
664
|
+
{#each topItems as item, idx (item.label)}
|
|
665
|
+
{@const id = `top:${idx}` as ItemId}
|
|
666
|
+
<li class="list-none">
|
|
667
|
+
{@render row(item, { id })}
|
|
668
|
+
</li>
|
|
669
|
+
{#if (item.children?.length ?? 0) > 0 && !isStepped}
|
|
670
|
+
{@const geom = geometryFor(id, "top", idx, topItems.length, item.children!.length)}
|
|
671
|
+
<Popup
|
|
672
|
+
anchor={geom.anchor}
|
|
673
|
+
open={openId === id}
|
|
674
|
+
side="right"
|
|
675
|
+
align={geom.align}
|
|
676
|
+
offset={0}
|
|
677
|
+
onclose={() => {
|
|
678
|
+
if (openId === id) openId = null;
|
|
679
|
+
}}
|
|
680
|
+
>
|
|
681
|
+
{@render popout(item, geom.borderClass)}
|
|
682
|
+
</Popup>
|
|
683
|
+
{/if}
|
|
684
|
+
{/each}
|
|
685
|
+
</ul>
|
|
686
|
+
{/if}
|
|
687
|
+
|
|
688
|
+
{#if topItems.length > 0 && bottomItems.length > 0}
|
|
689
|
+
<div class="flex-1"></div>
|
|
690
|
+
{/if}
|
|
691
|
+
|
|
692
|
+
{#if bottomItems.length > 0}
|
|
693
|
+
<ul class="flex flex-col">
|
|
694
|
+
{#each bottomItems as item, idx (item.label)}
|
|
695
|
+
{@const id = `bottom:${idx}` as ItemId}
|
|
696
|
+
<li class="list-none">
|
|
697
|
+
{@render row(item, { id })}
|
|
698
|
+
</li>
|
|
699
|
+
{#if (item.children?.length ?? 0) > 0 && !isStepped}
|
|
700
|
+
{@const geom = geometryFor(
|
|
701
|
+
id,
|
|
702
|
+
"bottom",
|
|
703
|
+
idx,
|
|
704
|
+
bottomItems.length,
|
|
705
|
+
item.children!.length,
|
|
706
|
+
)}
|
|
707
|
+
<Popup
|
|
708
|
+
anchor={geom.anchor}
|
|
709
|
+
open={openId === id}
|
|
710
|
+
side="right"
|
|
711
|
+
align={geom.align}
|
|
712
|
+
offset={0}
|
|
713
|
+
onclose={() => {
|
|
714
|
+
if (openId === id) openId = null;
|
|
715
|
+
}}
|
|
716
|
+
>
|
|
717
|
+
{@render popout(item, geom.borderClass)}
|
|
718
|
+
</Popup>
|
|
719
|
+
{/if}
|
|
720
|
+
{/each}
|
|
721
|
+
</ul>
|
|
722
|
+
{/if}
|
|
723
|
+
{/if}
|
|
724
|
+
</aside>
|