@orochibraru/ui 0.0.0 → 1.0.0-canary.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +97 -21
- package/dist/components/blocks/accent-toggle.svelte +85 -0
- package/dist/components/blocks/accent-toggle.svelte.d.ts +18 -0
- package/dist/components/blocks/bottom-nav.svelte +67 -0
- package/dist/components/blocks/bottom-nav.svelte.d.ts +18 -0
- package/dist/components/blocks/music-player/format-time.d.ts +2 -0
- package/dist/components/blocks/music-player/format-time.js +13 -0
- package/dist/components/blocks/music-player/labelled-slider.svelte +36 -0
- package/dist/components/blocks/music-player/labelled-slider.svelte.d.ts +15 -0
- package/dist/components/blocks/music-player/volume-control.svelte +48 -0
- package/dist/components/blocks/music-player/volume-control.svelte.d.ts +7 -0
- package/dist/components/blocks/music-player.svelte +263 -0
- package/dist/components/blocks/music-player.svelte.d.ts +26 -0
- package/dist/components/blocks/nav-cards.svelte +42 -0
- package/dist/components/blocks/nav-cards.svelte.d.ts +15 -0
- package/dist/components/blocks/new-password-fields.svelte +127 -0
- package/dist/components/blocks/new-password-fields.svelte.d.ts +19 -0
- package/dist/components/blocks/option-group.svelte +71 -0
- package/dist/components/blocks/option-group.svelte.d.ts +20 -0
- package/dist/components/blocks/otp-field.svelte +59 -0
- package/dist/components/blocks/otp-field.svelte.d.ts +12 -0
- package/dist/components/blocks/password-input.svelte +51 -0
- package/dist/components/blocks/password-input.svelte.d.ts +11 -0
- package/dist/components/blocks/stat-card.svelte +1 -1
- package/dist/components/blocks/tab-nav.svelte +38 -0
- package/dist/components/blocks/tab-nav.svelte.d.ts +14 -0
- package/dist/components/blocks/tab-section.svelte +48 -0
- package/dist/components/blocks/tab-section.svelte.d.ts +12 -0
- package/dist/components/blocks/theme-picker.svelte +69 -0
- package/dist/components/blocks/theme-picker.svelte.d.ts +16 -0
- package/dist/components/blocks/theme-toggle.svelte +68 -0
- package/dist/components/blocks/theme-toggle.svelte.d.ts +12 -0
- package/dist/components/blocks/video-player/center-controls.svelte +54 -0
- package/dist/components/blocks/video-player/center-controls.svelte.d.ts +10 -0
- package/dist/components/blocks/video-player/control-bar.svelte +184 -0
- package/dist/components/blocks/video-player/control-bar.svelte.d.ts +18 -0
- package/dist/components/blocks/video-player/control-button.svelte +54 -0
- package/dist/components/blocks/video-player/control-button.svelte.d.ts +13 -0
- package/dist/components/blocks/video-player/format.d.ts +9 -0
- package/dist/components/blocks/video-player/format.js +24 -0
- package/dist/components/blocks/video-player/gesture-feedback.svelte +32 -0
- package/dist/components/blocks/video-player/gesture-feedback.svelte.d.ts +7 -0
- package/dist/components/blocks/video-player/gestures.d.ts +13 -0
- package/dist/components/blocks/video-player/gestures.js +25 -0
- package/dist/components/blocks/video-player/keymap.d.ts +14 -0
- package/dist/components/blocks/video-player/keymap.js +55 -0
- package/dist/components/blocks/video-player/messages.d.ts +22 -0
- package/dist/components/blocks/video-player/messages.js +21 -0
- package/dist/components/blocks/video-player/option-menu.svelte +85 -0
- package/dist/components/blocks/video-player/option-menu.svelte.d.ts +18 -0
- package/dist/components/blocks/video-player/player.svelte.d.ts +37 -0
- package/dist/components/blocks/video-player/player.svelte.js +115 -0
- package/dist/components/blocks/video-player/scrub-bar.svelte +95 -0
- package/dist/components/blocks/video-player/scrub-bar.svelte.d.ts +12 -0
- package/dist/components/blocks/video-player/tap-gestures.svelte.d.ts +19 -0
- package/dist/components/blocks/video-player/tap-gestures.svelte.js +56 -0
- package/dist/components/blocks/video-player.svelte +288 -0
- package/dist/components/blocks/video-player.svelte.d.ts +29 -0
- package/dist/components/ui/accordion/accordion.svelte.d.ts +1 -1
- package/dist/components/ui/calendar/calendar.svelte.d.ts +1 -1
- package/dist/components/ui/command/command-dialog.svelte.d.ts +1 -1
- package/dist/components/ui/command/command-input.svelte.d.ts +1 -1
- package/dist/components/ui/command/command.svelte.d.ts +1 -1
- package/dist/components/ui/context-menu/context-menu-radio-group.svelte.d.ts +1 -1
- package/dist/components/ui/dropdown-menu/dropdown-menu-checkbox-group.svelte.d.ts +1 -1
- package/dist/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte.d.ts +1 -1
- package/dist/components/ui/input/input.svelte +2 -2
- package/dist/components/ui/input/input.svelte.d.ts +1 -1
- package/dist/components/ui/input-group/input-group-input.svelte.d.ts +2 -2
- package/dist/components/ui/input-group/input-group-textarea.svelte.d.ts +1 -1
- package/dist/components/ui/input-group/input-group.svelte +1 -1
- package/dist/components/ui/input-otp/input-otp-slot.svelte +1 -1
- package/dist/components/ui/input-otp/input-otp.svelte.d.ts +1 -1
- package/dist/components/ui/menubar/menubar-radio-group.svelte.d.ts +1 -1
- package/dist/components/ui/radio-group/radio-group.svelte.d.ts +1 -1
- package/dist/components/ui/range-calendar/range-calendar.svelte.d.ts +1 -1
- package/dist/components/ui/select/select-trigger.svelte +1 -1
- package/dist/components/ui/sidebar/sidebar-input.svelte.d.ts +2 -2
- package/dist/components/ui/slider/slider.svelte.d.ts +1 -1
- package/dist/components/ui/tabs/tabs.svelte.d.ts +1 -1
- package/dist/components/ui/textarea/textarea.svelte +1 -1
- package/dist/components/ui/textarea/textarea.svelte.d.ts +1 -1
- package/dist/components/ui/toggle/toggle.svelte.d.ts +1 -1
- package/dist/components/ui/toggle-group/toggle-group-item.svelte.d.ts +1 -1
- package/dist/components/ui/toggle-group/toggle-group.svelte.d.ts +1 -1
- package/dist/mcp-content.json +1 -0
- package/dist/mcp.js +83 -0
- package/dist/password.d.ts +11 -0
- package/dist/password.js +38 -0
- package/package.json +15 -3
package/README.md
CHANGED
|
@@ -59,26 +59,58 @@ a search box never gets a login suggestion.
|
|
|
59
59
|
|
|
60
60
|
Under `@orochibraru/ui/blocks/<name>`:
|
|
61
61
|
|
|
62
|
-
| Block
|
|
63
|
-
|
|
|
64
|
-
| `
|
|
65
|
-
| `
|
|
66
|
-
| `
|
|
67
|
-
| `
|
|
68
|
-
| `
|
|
69
|
-
| `
|
|
70
|
-
| `
|
|
71
|
-
| `
|
|
72
|
-
| `
|
|
73
|
-
| `
|
|
74
|
-
| `
|
|
75
|
-
| `
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
`
|
|
79
|
-
|
|
62
|
+
| Block | What it does |
|
|
63
|
+
| --------------------- | ----------------------------------------------------------------------------- |
|
|
64
|
+
| `accent-toggle` | The accent presets: swatch strip on desktop, labelled rows on phones. |
|
|
65
|
+
| `auth-shell` | Centered sign-in layout: heading, optional `brand` and `footer` snippets. |
|
|
66
|
+
| `bottom-nav` | Phone tab bar of top-level destinations, the current one lit. |
|
|
67
|
+
| `copy-field` | Monospace value with a copy button and a promise toast. |
|
|
68
|
+
| `document-title` | `<title>` from the page title, suffixed with `appName`. |
|
|
69
|
+
| `empty-state` | Icon, title, subtitle and an action slot for an empty list. |
|
|
70
|
+
| `error-page` | Status, message and error id from `page`, with a link to `homeHref`. |
|
|
71
|
+
| `fab` | Floating action button above the phone tab bar, with a haptic tap. |
|
|
72
|
+
| `icon-button` | Round icon button with a tooltip that doubles as its accessible name. |
|
|
73
|
+
| `install-prompt` | One-time PWA install sheet: native prompt, iOS steps or browser menu steps. |
|
|
74
|
+
| `music-player` | Audio bar with a queue: play, skip, seek, volume; positioning is the app's. |
|
|
75
|
+
| `nav-cards` | Phone-only list of a section's tabs as cards: icon, label, description. |
|
|
76
|
+
| `new-password-fields` | New password and its confirmation: reveal, strength meter, rules. |
|
|
77
|
+
| `option-group` | Radio cards for a settings choice, with an icon or a `swatch` snippet each. |
|
|
78
|
+
| `otp-field` | One-time code: digits only, SMS autofill, posted as one `code` field. |
|
|
79
|
+
| `password-input` | Password field with a button to reveal it. |
|
|
80
|
+
| `responsive-sheet` | A dialog on desktop, a bottom drawer under 768px. |
|
|
81
|
+
| `setting-switch` | Labelled switch row for settings forms, posting `name=true` when on. |
|
|
82
|
+
| `stat-card` | Glass card with an icon, a value and a hint, optionally a link. |
|
|
83
|
+
| `tab-nav` | Pill tab bar of links, the current page's tab highlighted. |
|
|
84
|
+
| `tab-section` | Layout for a tabbed section: tabs on desktop, nav cards on phones. |
|
|
85
|
+
| `theme-picker` | System, light and dark as radio cards, for an appearance page. |
|
|
86
|
+
| `theme-toggle` | System, light and dark: icon strip on desktop, three rows on phones. |
|
|
87
|
+
| `top-loading-bar` | Progress bar during SvelteKit navigations, safe-area aware. |
|
|
88
|
+
| `video-player` | Video with scrubbing, chapters, subtitles, speed, fullscreen, gestures, keys. |
|
|
89
|
+
|
|
90
|
+
Blocks with built-in copy (`accent-toggle`, `copy-field`, `error-page`,
|
|
91
|
+
`install-prompt`, `new-password-fields`, `password-input`, `theme-picker`,
|
|
92
|
+
`theme-toggle`) take a `messages` prop. Its keys override the English defaults
|
|
93
|
+
one by one, which is how a French or translated app uses them. Blocks never set
|
|
94
|
+
the page title, except `tab-section`, which owns its section's title: the app
|
|
80
95
|
calls `setPageTitle` itself.
|
|
81
96
|
|
|
97
|
+
### Tabbed sections
|
|
98
|
+
|
|
99
|
+
A tabbed page is one real route per tab, each tab with a description, under a
|
|
100
|
+
`tab-section` layout. On phones the index route renders `nav-cards` and each tab
|
|
101
|
+
is a page titled by its label, its back button returning to the cards. On
|
|
102
|
+
desktop the index replaces itself with the first tab and `tab-nav` shows them
|
|
103
|
+
all. The tabs live in one list (`SectionTab[]`, exported by `nav-cards`) that
|
|
104
|
+
the layout and the index page share.
|
|
105
|
+
|
|
106
|
+
### Theme
|
|
107
|
+
|
|
108
|
+
`theme-picker` and `theme-toggle` call `mode-watcher`'s `setMode`, so the app
|
|
109
|
+
mounts `<ModeWatcher />` in its root layout. Both take the saved theme as
|
|
110
|
+
`value`, so the server-rendered page checks the right one, and call `onchange`
|
|
111
|
+
with the new theme for the app to persist. `theme-picker` also posts as a
|
|
112
|
+
`theme` radio field (`name` changes it) inside a form.
|
|
113
|
+
|
|
82
114
|
`icon-button`, and `copy-field` through it, need a `Tooltip.Provider` above
|
|
83
115
|
them, which apps put in their root layout.
|
|
84
116
|
|
|
@@ -94,6 +126,9 @@ them, which apps put in their root layout.
|
|
|
94
126
|
- `@orochibraru/ui/appearance`: `applyAppearance` and `appearanceBootScript`
|
|
95
127
|
apply a saved accent, theme, dark variant, font and corner style to `<html>`,
|
|
96
128
|
the boot script before first paint.
|
|
129
|
+
- `@orochibraru/ui/password`: `passwordRules` and `passwordScore`, the rules a
|
|
130
|
+
new password must pass and a 0 to 4 strength, the same on the form and the
|
|
131
|
+
server. `new-password-fields` renders them; `ruleLabels` translates them.
|
|
97
132
|
- `@orochibraru/ui/haptics`: `haptic("tap" | "success" | "error")`, a no-op
|
|
98
133
|
where vibration isn't supported.
|
|
99
134
|
|
|
@@ -133,9 +168,23 @@ bun install
|
|
|
133
168
|
bun run dev
|
|
134
169
|
```
|
|
135
170
|
|
|
136
|
-
The dev server
|
|
137
|
-
|
|
138
|
-
|
|
171
|
+
The dev server is the docs site: a page per block, primitive and module, with
|
|
172
|
+
the component live and the code that renders it. That code is the example file
|
|
173
|
+
itself, its `#lib` imports rewritten to the package subpaths, so the two can't
|
|
174
|
+
drift apart. A file named `<slug>.<name>` next to an example is a companion,
|
|
175
|
+
shown after it: the layout, action or page an app needs for what the preview
|
|
176
|
+
can't hold. An example without a script is a note for the preview only.
|
|
177
|
+
|
|
178
|
+
Every component page can be copied as Markdown, viewed as Markdown, exported as
|
|
179
|
+
a PDF (through the browser's print dialog, in the light theme), or opened in
|
|
180
|
+
Claude or ChatGPT. The list pages filter by category.
|
|
181
|
+
|
|
182
|
+
The `Dockerfile` deploys the docs site: `bun run build:docs` compiles it to one
|
|
183
|
+
Bun binary that serves on port 3000 and reads `x-forwarded-proto` and
|
|
184
|
+
`x-forwarded-host`, so llms.txt links point at the public URL behind a proxy.
|
|
185
|
+
|
|
186
|
+
`bun run check`, `bun run test` and `bun run build` are the gates; `build`
|
|
187
|
+
packages `dist/` and runs publint.
|
|
139
188
|
|
|
140
189
|
New upstream components come from the shadcn-svelte CLI (`components.json` is
|
|
141
190
|
set up), followed by `node_modules/.bin/tailwint --fix` on the new files. The
|
|
@@ -145,3 +194,30 @@ put them back after.
|
|
|
145
194
|
|
|
146
195
|
Dependencies use caret ranges, unlike the apps' exact pins, so a consuming app
|
|
147
196
|
dedupes `bits-ui` and friends with its own copy instead of bundling two.
|
|
197
|
+
|
|
198
|
+
## MCP server
|
|
199
|
+
|
|
200
|
+
The package ships an MCP server with the docs and every example's code bundled
|
|
201
|
+
in, so an editor's assistant can list, search and read the components without
|
|
202
|
+
the docs site. It runs over stdio from the package's `bin`:
|
|
203
|
+
|
|
204
|
+
```bash
|
|
205
|
+
claude mcp add orochibraru-ui -- npx -y @orochibraru/ui
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
Cursor, VS Code and other clients take the same command:
|
|
209
|
+
|
|
210
|
+
```json
|
|
211
|
+
{
|
|
212
|
+
"mcpServers": {
|
|
213
|
+
"orochibraru-ui": { "command": "npx", "args": ["-y", "@orochibraru/ui"] }
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
Its tools are `get_setup` (this README), `list_components` (by group or
|
|
219
|
+
category), `search_components` and `get_component` (the description, import path
|
|
220
|
+
and usage code). `bun run build` bundles it with its content, so it needs no
|
|
221
|
+
dependency at runtime. The docs site serves the same server over streamable HTTP
|
|
222
|
+
at `/mcp`, plus `/llms.txt`, `/llms-full.txt` and a Markdown page per component
|
|
223
|
+
at `/<group>/<slug>.md`.
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { MediaQuery } from "svelte/reactivity";
|
|
3
|
+
|
|
4
|
+
import * as ToggleGroup from "../ui/toggle-group/index.js";
|
|
5
|
+
|
|
6
|
+
type Accent =
|
|
7
|
+
| "blue"
|
|
8
|
+
| "bordeaux"
|
|
9
|
+
| "green"
|
|
10
|
+
| "violet"
|
|
11
|
+
| "rose"
|
|
12
|
+
| "orange"
|
|
13
|
+
| "amber"
|
|
14
|
+
| "cyan"
|
|
15
|
+
| "neutral";
|
|
16
|
+
|
|
17
|
+
const defaultMessages = {
|
|
18
|
+
label: "Accent",
|
|
19
|
+
blue: "Blue",
|
|
20
|
+
bordeaux: "Bordeaux",
|
|
21
|
+
green: "Green",
|
|
22
|
+
violet: "Violet",
|
|
23
|
+
rose: "Rose",
|
|
24
|
+
orange: "Orange",
|
|
25
|
+
amber: "Amber",
|
|
26
|
+
cyan: "Cyan",
|
|
27
|
+
neutral: "Neutral",
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
interface Props {
|
|
31
|
+
value?: Accent;
|
|
32
|
+
onchange?: (accent: Accent) => void;
|
|
33
|
+
messages?: Partial<typeof defaultMessages>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
let { value = $bindable("blue"), onchange, messages = {} }: Props = $props();
|
|
37
|
+
|
|
38
|
+
const accents: Accent[] = [
|
|
39
|
+
"blue",
|
|
40
|
+
"bordeaux",
|
|
41
|
+
"green",
|
|
42
|
+
"violet",
|
|
43
|
+
"rose",
|
|
44
|
+
"orange",
|
|
45
|
+
"amber",
|
|
46
|
+
"cyan",
|
|
47
|
+
"neutral",
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
const desktop = new MediaQuery("(min-width: 768px)");
|
|
51
|
+
const text = $derived({ ...defaultMessages, ...messages });
|
|
52
|
+
|
|
53
|
+
function pick(next: string) {
|
|
54
|
+
if (!next) {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
value = next as Accent;
|
|
58
|
+
document.documentElement.dataset.accent = value;
|
|
59
|
+
onchange?.(value);
|
|
60
|
+
}
|
|
61
|
+
</script>
|
|
62
|
+
|
|
63
|
+
<ToggleGroup.Root
|
|
64
|
+
type="single"
|
|
65
|
+
variant="outline"
|
|
66
|
+
orientation={desktop.current ? "horizontal" : "vertical"}
|
|
67
|
+
aria-label={text.label}
|
|
68
|
+
class="max-md:w-full"
|
|
69
|
+
bind:value={() => value, pick}
|
|
70
|
+
>
|
|
71
|
+
{#each accents as accent (accent)}
|
|
72
|
+
<ToggleGroup.Item
|
|
73
|
+
value={accent}
|
|
74
|
+
aria-label={text[accent]}
|
|
75
|
+
title={text[accent]}
|
|
76
|
+
data-accent={accent}
|
|
77
|
+
class="max-md:h-12 max-md:w-full max-md:justify-start max-md:gap-3 max-md:px-4 md:size-9"
|
|
78
|
+
>
|
|
79
|
+
<span
|
|
80
|
+
class="size-3 rounded-full bg-[oklch(0.6_var(--primary-chroma)_var(--hue))]"
|
|
81
|
+
></span>
|
|
82
|
+
<span class="md:hidden">{text[accent]}</span>
|
|
83
|
+
</ToggleGroup.Item>
|
|
84
|
+
{/each}
|
|
85
|
+
</ToggleGroup.Root>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
declare const AccentToggle: import("svelte").Component<{
|
|
2
|
+
value?: "blue" | "bordeaux" | "green" | "violet" | "rose" | "orange" | "amber" | "cyan" | "neutral";
|
|
3
|
+
onchange?: (accent: "blue" | "bordeaux" | "green" | "violet" | "rose" | "orange" | "amber" | "cyan" | "neutral") => void;
|
|
4
|
+
messages?: Partial<{
|
|
5
|
+
label: string;
|
|
6
|
+
blue: string;
|
|
7
|
+
bordeaux: string;
|
|
8
|
+
green: string;
|
|
9
|
+
violet: string;
|
|
10
|
+
rose: string;
|
|
11
|
+
orange: string;
|
|
12
|
+
amber: string;
|
|
13
|
+
cyan: string;
|
|
14
|
+
neutral: string;
|
|
15
|
+
}>;
|
|
16
|
+
}, {}, "value">;
|
|
17
|
+
type AccentToggle = ReturnType<typeof AccentToggle>;
|
|
18
|
+
export default AccentToggle;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
import type { Component } from "svelte";
|
|
3
|
+
|
|
4
|
+
export interface NavItem {
|
|
5
|
+
href: string;
|
|
6
|
+
label: string;
|
|
7
|
+
icon: Component<{ class?: string }>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Whether `pathname` sits under the destination at `href`; the root only matches itself. */
|
|
11
|
+
export function isUnder(pathname: string, href: string): boolean {
|
|
12
|
+
return href === "/"
|
|
13
|
+
? pathname === "/"
|
|
14
|
+
: pathname === href || pathname.startsWith(`${href}/`);
|
|
15
|
+
}
|
|
16
|
+
</script>
|
|
17
|
+
|
|
18
|
+
<script lang="ts">
|
|
19
|
+
import { page } from "$app/state";
|
|
20
|
+
|
|
21
|
+
interface Props {
|
|
22
|
+
items: NavItem[];
|
|
23
|
+
isActive?: (pathname: string, href: string) => boolean;
|
|
24
|
+
label?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const { items, isActive = isUnder, label = "Main" }: Props = $props();
|
|
28
|
+
|
|
29
|
+
let compact = $state(false);
|
|
30
|
+
let lastY = 0;
|
|
31
|
+
|
|
32
|
+
function onscroll() {
|
|
33
|
+
const y = window.scrollY;
|
|
34
|
+
if (Math.abs(y - lastY) < 8) {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
compact = y > lastY && y > 64;
|
|
38
|
+
lastY = y;
|
|
39
|
+
}
|
|
40
|
+
</script>
|
|
41
|
+
|
|
42
|
+
<svelte:window {onscroll} />
|
|
43
|
+
|
|
44
|
+
<nav
|
|
45
|
+
class="group/nav fixed inset-x-0 bottom-0 z-40 px-4 pb-[max(0.75rem,env(safe-area-inset-bottom))] md:hidden print:hidden"
|
|
46
|
+
style="view-transition-name: tab-bar"
|
|
47
|
+
aria-label={label}
|
|
48
|
+
data-compact={compact || undefined}
|
|
49
|
+
>
|
|
50
|
+
<ul
|
|
51
|
+
class="bg-popover border-border mx-auto flex max-w-sm items-center gap-1 rounded-full border p-1.5 shadow-lg"
|
|
52
|
+
>
|
|
53
|
+
{#each items as item (item.href)}
|
|
54
|
+
{@const active = isActive(page.url.pathname, item.href)}
|
|
55
|
+
<li class="flex-1">
|
|
56
|
+
<a
|
|
57
|
+
href={item.href}
|
|
58
|
+
aria-current={active ? "page" : undefined}
|
|
59
|
+
class="text-muted-foreground aria-[current=page]:bg-primary aria-[current=page]:text-primary-foreground aria-[current=page]:glow flex h-12 flex-col items-center justify-center gap-0.5 rounded-full px-0.5 text-[0.6875rem] font-medium tracking-tight transition-all group-data-compact/nav:h-10 active:scale-95"
|
|
60
|
+
>
|
|
61
|
+
<item.icon class="size-5" />
|
|
62
|
+
<span class="group-data-compact/nav:sr-only">{item.label}</span>
|
|
63
|
+
</a>
|
|
64
|
+
</li>
|
|
65
|
+
{/each}
|
|
66
|
+
</ul>
|
|
67
|
+
</nav>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Component } from "svelte";
|
|
2
|
+
export interface NavItem {
|
|
3
|
+
href: string;
|
|
4
|
+
label: string;
|
|
5
|
+
icon: Component<{
|
|
6
|
+
class?: string;
|
|
7
|
+
}>;
|
|
8
|
+
}
|
|
9
|
+
/** Whether `pathname` sits under the destination at `href`; the root only matches itself. */
|
|
10
|
+
export declare function isUnder(pathname: string, href: string): boolean;
|
|
11
|
+
interface Props {
|
|
12
|
+
items: NavItem[];
|
|
13
|
+
isActive?: (pathname: string, href: string) => boolean;
|
|
14
|
+
label?: string;
|
|
15
|
+
}
|
|
16
|
+
declare const BottomNav: Component<Props, {}, "">;
|
|
17
|
+
type BottomNav = ReturnType<typeof BottomNav>;
|
|
18
|
+
export default BottomNav;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** `m:ss`, or `h:mm:ss` past an hour; an unknown or endless duration reads `0:00`. */
|
|
2
|
+
export function formatTime(seconds) {
|
|
3
|
+
if (!Number.isFinite(seconds) || seconds < 0) {
|
|
4
|
+
return "0:00";
|
|
5
|
+
}
|
|
6
|
+
const whole = Math.floor(seconds);
|
|
7
|
+
const hours = Math.floor(whole / 3600);
|
|
8
|
+
const minutes = Math.floor((whole % 3600) / 60);
|
|
9
|
+
const rest = String(whole % 60).padStart(2, "0");
|
|
10
|
+
return hours > 0
|
|
11
|
+
? `${hours}:${String(minutes).padStart(2, "0")}:${rest}`
|
|
12
|
+
: `${minutes}:${rest}`;
|
|
13
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Slider } from "../../ui/slider/index.js";
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
value: number;
|
|
6
|
+
max: number;
|
|
7
|
+
step: number;
|
|
8
|
+
label: string;
|
|
9
|
+
valueText: string;
|
|
10
|
+
onchange?: (value: number) => void;
|
|
11
|
+
oncommit?: (value: number) => void;
|
|
12
|
+
orientation?: "horizontal" | "vertical";
|
|
13
|
+
disabled?: boolean;
|
|
14
|
+
class?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const { value, label, valueText, onchange, oncommit, ...rest }: Props =
|
|
18
|
+
$props();
|
|
19
|
+
|
|
20
|
+
let ref: HTMLElement | null = $state(null);
|
|
21
|
+
|
|
22
|
+
$effect(() => {
|
|
23
|
+
const thumb = ref?.querySelector("[role=slider]");
|
|
24
|
+
thumb?.setAttribute("aria-label", label);
|
|
25
|
+
thumb?.setAttribute("aria-valuetext", valueText);
|
|
26
|
+
});
|
|
27
|
+
</script>
|
|
28
|
+
|
|
29
|
+
<Slider
|
|
30
|
+
bind:ref
|
|
31
|
+
type="single"
|
|
32
|
+
{value}
|
|
33
|
+
onValueChange={onchange}
|
|
34
|
+
onValueCommit={oncommit}
|
|
35
|
+
{...rest}
|
|
36
|
+
/>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
interface Props {
|
|
2
|
+
value: number;
|
|
3
|
+
max: number;
|
|
4
|
+
step: number;
|
|
5
|
+
label: string;
|
|
6
|
+
valueText: string;
|
|
7
|
+
onchange?: (value: number) => void;
|
|
8
|
+
oncommit?: (value: number) => void;
|
|
9
|
+
orientation?: "horizontal" | "vertical";
|
|
10
|
+
disabled?: boolean;
|
|
11
|
+
class?: string;
|
|
12
|
+
}
|
|
13
|
+
declare const LabelledSlider: import("svelte").Component<Props, {}, "">;
|
|
14
|
+
type LabelledSlider = ReturnType<typeof LabelledSlider>;
|
|
15
|
+
export default LabelledSlider;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import Volume1Icon from "@lucide/svelte/icons/volume-1";
|
|
3
|
+
import Volume2Icon from "@lucide/svelte/icons/volume-2";
|
|
4
|
+
import VolumeXIcon from "@lucide/svelte/icons/volume-x";
|
|
5
|
+
|
|
6
|
+
import { Button } from "../../ui/button/index.js";
|
|
7
|
+
import * as Popover from "../../ui/popover/index.js";
|
|
8
|
+
|
|
9
|
+
import LabelledSlider from "./labelled-slider.svelte";
|
|
10
|
+
|
|
11
|
+
interface Props {
|
|
12
|
+
volume: number;
|
|
13
|
+
label: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
let { volume = $bindable(1), label }: Props = $props();
|
|
17
|
+
|
|
18
|
+
const Icon = $derived(
|
|
19
|
+
volume === 0 ? VolumeXIcon : volume < 0.5 ? Volume1Icon : Volume2Icon,
|
|
20
|
+
);
|
|
21
|
+
</script>
|
|
22
|
+
|
|
23
|
+
<Popover.Root>
|
|
24
|
+
<Popover.Trigger>
|
|
25
|
+
{#snippet child({ props })}
|
|
26
|
+
<Button
|
|
27
|
+
{...props}
|
|
28
|
+
variant="ghost"
|
|
29
|
+
size="icon"
|
|
30
|
+
aria-label={label}
|
|
31
|
+
class="size-11 rounded-full md:size-9"
|
|
32
|
+
>
|
|
33
|
+
<Icon class="size-5" />
|
|
34
|
+
</Button>
|
|
35
|
+
{/snippet}
|
|
36
|
+
</Popover.Trigger>
|
|
37
|
+
<Popover.Content class="glass w-auto items-center rounded-2xl p-4">
|
|
38
|
+
<LabelledSlider
|
|
39
|
+
orientation="vertical"
|
|
40
|
+
value={volume}
|
|
41
|
+
max={1}
|
|
42
|
+
step={0.01}
|
|
43
|
+
{label}
|
|
44
|
+
valueText={`${Math.round(volume * 100)}%`}
|
|
45
|
+
onchange={(value) => (volume = value)}
|
|
46
|
+
/>
|
|
47
|
+
</Popover.Content>
|
|
48
|
+
</Popover.Root>
|