@nibapp/ui 0.0.0-stage → 0.1.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/CHANGELOG.md +35 -0
- package/LICENSE +21 -0
- package/README.md +124 -2
- package/TRADEMARKS.md +22 -0
- package/dist/_bundled/money/index.d.ts +98 -0
- package/dist/_bundled/money/index.js +152 -0
- package/dist/_bundled/tokens/index.d.ts +2 -0
- package/dist/_bundled/tokens/index.js +2 -0
- package/dist/_bundled/tokens/palette.gen.d.ts +51 -0
- package/dist/_bundled/tokens/palette.gen.js +206 -0
- package/dist/_bundled/tokens/spec.d.ts +291 -0
- package/dist/_bundled/tokens/spec.js +221 -0
- package/dist/components/accordion.d.ts +27 -0
- package/dist/components/accordion.js +66 -0
- package/dist/components/alert-dialog.d.ts +59 -0
- package/dist/components/alert-dialog.js +82 -0
- package/dist/components/alert.d.ts +141 -0
- package/dist/components/alert.js +55 -0
- package/dist/components/appear.d.ts +21 -0
- package/dist/components/appear.js +42 -0
- package/dist/components/avatar.d.ts +196 -0
- package/dist/components/avatar.js +135 -0
- package/dist/components/badge.d.ts +183 -0
- package/dist/components/badge.js +68 -0
- package/dist/components/block-stack.d.ts +33 -0
- package/dist/components/block-stack.js +197 -0
- package/dist/components/button.d.ts +327 -0
- package/dist/components/button.js +101 -0
- package/dist/components/card.d.ts +131 -0
- package/dist/components/card.js +100 -0
- package/dist/components/checkbox.d.ts +34 -0
- package/dist/components/checkbox.js +37 -0
- package/dist/components/choice-row.d.ts +104 -0
- package/dist/components/choice-row.js +56 -0
- package/dist/components/clamped-text.d.ts +17 -0
- package/dist/components/clamped-text.js +47 -0
- package/dist/components/code-block.d.ts +48 -0
- package/dist/components/code-block.js +124 -0
- package/dist/components/code-input.d.ts +49 -0
- package/dist/components/code-input.js +59 -0
- package/dist/components/combobox.d.ts +194 -0
- package/dist/components/combobox.js +176 -0
- package/dist/components/command-palette.d.ts +57 -0
- package/dist/components/command-palette.js +83 -0
- package/dist/components/context-menu.d.ts +24 -0
- package/dist/components/context-menu.js +30 -0
- package/dist/components/copy-button.d.ts +9 -0
- package/dist/components/copy-button.js +20 -0
- package/dist/components/copy-command.d.ts +18 -0
- package/dist/components/copy-command.js +19 -0
- package/dist/components/data-table.d.ts +240 -0
- package/dist/components/data-table.js +63 -0
- package/dist/components/dialog.d.ts +80 -0
- package/dist/components/dialog.js +85 -0
- package/dist/components/diff-lines.d.ts +20 -0
- package/dist/components/diff-lines.js +10 -0
- package/dist/components/diff-view.d.ts +128 -0
- package/dist/components/diff-view.js +675 -0
- package/dist/components/disclosure.d.ts +33 -0
- package/dist/components/disclosure.js +51 -0
- package/dist/components/drawn-check.d.ts +8 -0
- package/dist/components/drawn-check.js +16 -0
- package/dist/components/edge-resizer.d.ts +26 -0
- package/dist/components/edge-resizer.js +53 -0
- package/dist/components/empty-state.d.ts +112 -0
- package/dist/components/empty-state.js +56 -0
- package/dist/components/expand-toggle.d.ts +19 -0
- package/dist/components/expand-toggle.js +10 -0
- package/dist/components/field.d.ts +48 -0
- package/dist/components/field.js +60 -0
- package/dist/components/filter-chip.d.ts +47 -0
- package/dist/components/filter-chip.js +33 -0
- package/dist/components/group.d.ts +10 -0
- package/dist/components/group.js +9 -0
- package/dist/components/icon-text.d.ts +11 -0
- package/dist/components/icon-text.js +10 -0
- package/dist/components/image-sources.d.ts +57 -0
- package/dist/components/image-sources.js +58 -0
- package/dist/components/image-thumbnail.d.ts +34 -0
- package/dist/components/image-thumbnail.js +58 -0
- package/dist/components/image-viewer.d.ts +60 -0
- package/dist/components/image-viewer.js +411 -0
- package/dist/components/inline-edit.d.ts +22 -0
- package/dist/components/inline-edit.js +51 -0
- package/dist/components/input.d.ts +155 -0
- package/dist/components/input.js +109 -0
- package/dist/components/item-label.d.ts +11 -0
- package/dist/components/item-label.js +5 -0
- package/dist/components/item-row.d.ts +23 -0
- package/dist/components/item-row.js +19 -0
- package/dist/components/kbd.d.ts +55 -0
- package/dist/components/kbd.js +86 -0
- package/dist/components/link-policy.d.ts +24 -0
- package/dist/components/link-policy.js +32 -0
- package/dist/components/linked-text.d.ts +15 -0
- package/dist/components/linked-text.js +48 -0
- package/dist/components/loading-region.d.ts +18 -0
- package/dist/components/loading-region.js +17 -0
- package/dist/components/markdown.d.ts +34 -0
- package/dist/components/markdown.js +307 -0
- package/dist/components/menu.d.ts +132 -0
- package/dist/components/menu.js +102 -0
- package/dist/components/message-bubble.d.ts +57 -0
- package/dist/components/message-bubble.js +28 -0
- package/dist/components/meta-line.d.ts +28 -0
- package/dist/components/meta-line.js +36 -0
- package/dist/components/money-input.d.ts +23 -0
- package/dist/components/money-input.js +34 -0
- package/dist/components/native-select.d.ts +120 -0
- package/dist/components/native-select.js +85 -0
- package/dist/components/page-trail.d.ts +30 -0
- package/dist/components/page-trail.js +43 -0
- package/dist/components/page.d.ts +80 -0
- package/dist/components/page.js +60 -0
- package/dist/components/path-text.d.ts +8 -0
- package/dist/components/path-text.js +11 -0
- package/dist/components/percent-input.d.ts +20 -0
- package/dist/components/percent-input.js +32 -0
- package/dist/components/popover.d.ts +52 -0
- package/dist/components/popover.js +36 -0
- package/dist/components/progress.d.ts +389 -0
- package/dist/components/progress.js +142 -0
- package/dist/components/radio.d.ts +65 -0
- package/dist/components/radio.js +54 -0
- package/dist/components/remote-image-gate.d.ts +34 -0
- package/dist/components/remote-image-gate.js +60 -0
- package/dist/components/resizable.d.ts +23 -0
- package/dist/components/resizable.js +30 -0
- package/dist/components/rolling-number.d.ts +13 -0
- package/dist/components/rolling-number.js +69 -0
- package/dist/components/scroll-area.d.ts +17 -0
- package/dist/components/scroll-area.js +25 -0
- package/dist/components/section-label.d.ts +10 -0
- package/dist/components/section-label.js +21 -0
- package/dist/components/section-nav.d.ts +22 -0
- package/dist/components/section-nav.js +80 -0
- package/dist/components/segmented-control.d.ts +125 -0
- package/dist/components/segmented-control.js +101 -0
- package/dist/components/select.d.ts +120 -0
- package/dist/components/select.js +89 -0
- package/dist/components/separator.d.ts +10 -0
- package/dist/components/separator.js +12 -0
- package/dist/components/sheet.d.ts +101 -0
- package/dist/components/sheet.js +129 -0
- package/dist/components/shortcut-recorder.d.ts +31 -0
- package/dist/components/shortcut-recorder.js +67 -0
- package/dist/components/skeleton.d.ts +31 -0
- package/dist/components/skeleton.js +33 -0
- package/dist/components/slider.d.ts +138 -0
- package/dist/components/slider.js +98 -0
- package/dist/components/spinner.d.ts +37 -0
- package/dist/components/spinner.js +16 -0
- package/dist/components/switch.d.ts +59 -0
- package/dist/components/switch.js +56 -0
- package/dist/components/tab-strip.d.ts +29 -0
- package/dist/components/tab-strip.js +42 -0
- package/dist/components/tabs.d.ts +13 -0
- package/dist/components/tabs.js +68 -0
- package/dist/components/text-link.d.ts +54 -0
- package/dist/components/text-link.js +38 -0
- package/dist/components/textarea.d.ts +75 -0
- package/dist/components/textarea.js +90 -0
- package/dist/components/toast.d.ts +74 -0
- package/dist/components/toast.js +128 -0
- package/dist/components/tooltip.d.ts +50 -0
- package/dist/components/tooltip.js +46 -0
- package/dist/components/truncated-text.d.ts +15 -0
- package/dist/components/truncated-text.js +19 -0
- package/dist/i18n/defaults.d.ts +7 -0
- package/dist/i18n/defaults.js +7 -0
- package/dist/i18n/format.d.ts +84 -0
- package/dist/i18n/format.js +169 -0
- package/dist/i18n/index.d.ts +6 -0
- package/dist/i18n/index.js +8 -0
- package/dist/i18n/locales/ru-formatters.d.ts +3 -0
- package/dist/i18n/locales/ru-formatters.js +22 -0
- package/dist/i18n/locales/ru.d.ts +5 -0
- package/dist/i18n/locales/ru.js +185 -0
- package/dist/i18n/plural.d.ts +20 -0
- package/dist/i18n/plural.js +26 -0
- package/dist/i18n/provider.d.ts +36 -0
- package/dist/i18n/provider.js +39 -0
- package/dist/i18n/rich.d.ts +14 -0
- package/dist/i18n/rich.js +44 -0
- package/dist/i18n/ru-plural.d.ts +3 -0
- package/dist/i18n/ru-plural.js +4 -0
- package/dist/i18n/strings/alert-dialog.d.ts +5 -0
- package/dist/i18n/strings/alert-dialog.js +5 -0
- package/dist/i18n/strings/block-stack.d.ts +17 -0
- package/dist/i18n/strings/block-stack.js +11 -0
- package/dist/i18n/strings/code-block.d.ts +18 -0
- package/dist/i18n/strings/code-block.js +13 -0
- package/dist/i18n/strings/combobox.d.ts +12 -0
- package/dist/i18n/strings/combobox.js +10 -0
- package/dist/i18n/strings/command-palette.d.ts +17 -0
- package/dist/i18n/strings/command-palette.js +11 -0
- package/dist/i18n/strings/diff-view.d.ts +92 -0
- package/dist/i18n/strings/diff-view.js +68 -0
- package/dist/i18n/strings/image-viewer.d.ts +43 -0
- package/dist/i18n/strings/image-viewer.js +32 -0
- package/dist/i18n/strings/index.d.ts +314 -0
- package/dist/i18n/strings/index.js +43 -0
- package/dist/i18n/strings/kbd.d.ts +42 -0
- package/dist/i18n/strings/kbd.js +39 -0
- package/dist/i18n/strings/markdown.d.ts +23 -0
- package/dist/i18n/strings/markdown.js +18 -0
- package/dist/i18n/strings/money.d.ts +7 -0
- package/dist/i18n/strings/money.js +5 -0
- package/dist/i18n/strings/primitives.d.ts +25 -0
- package/dist/i18n/strings/primitives.js +18 -0
- package/dist/i18n/strings/progress.d.ts +4 -0
- package/dist/i18n/strings/progress.js +4 -0
- package/dist/i18n/strings/shortcut-recorder.d.ts +15 -0
- package/dist/i18n/strings/shortcut-recorder.js +11 -0
- package/dist/i18n/strings/time.d.ts +41 -0
- package/dist/i18n/strings/time.js +20 -0
- package/dist/i18n/strings/toast.d.ts +6 -0
- package/dist/i18n/strings/toast.js +6 -0
- package/dist/i18n/time-zones.d.ts +20 -0
- package/dist/i18n/time-zones.js +64 -0
- package/dist/index.d.ts +84 -0
- package/dist/index.js +88 -0
- package/dist/lib/ansi.d.ts +55 -0
- package/dist/lib/ansi.js +438 -0
- package/dist/lib/clock.d.ts +32 -0
- package/dist/lib/clock.js +106 -0
- package/dist/lib/cn.d.ts +12 -0
- package/dist/lib/cn.js +28 -0
- package/dist/lib/control-props.d.ts +21 -0
- package/dist/lib/control-props.js +14 -0
- package/dist/lib/direction.d.ts +13 -0
- package/dist/lib/direction.js +25 -0
- package/dist/lib/image-size.d.ts +12 -0
- package/dist/lib/image-size.js +66 -0
- package/dist/lib/keycaps.d.ts +21 -0
- package/dist/lib/keycaps.js +62 -0
- package/dist/lib/keyed.d.ts +9 -0
- package/dist/lib/keyed.js +14 -0
- package/dist/lib/keys.d.ts +49 -0
- package/dist/lib/keys.js +159 -0
- package/dist/lib/kit-motion.d.ts +9 -0
- package/dist/lib/kit-motion.js +12 -0
- package/dist/lib/link-check-dialog.d.ts +11 -0
- package/dist/lib/link-check-dialog.js +18 -0
- package/dist/lib/motion-features.d.ts +1 -0
- package/dist/lib/motion-features.js +4 -0
- package/dist/lib/motion-provider.d.ts +5 -0
- package/dist/lib/motion-provider.js +8 -0
- package/dist/lib/motion.d.ts +39 -0
- package/dist/lib/motion.js +49 -0
- package/dist/lib/number-format.d.ts +114 -0
- package/dist/lib/number-format.js +313 -0
- package/dist/lib/patch.d.ts +72 -0
- package/dist/lib/patch.js +344 -0
- package/dist/lib/path.d.ts +34 -0
- package/dist/lib/path.js +85 -0
- package/dist/lib/search.d.ts +10 -0
- package/dist/lib/search.js +20 -0
- package/dist/lib/shiki-themes.d.ts +7 -0
- package/dist/lib/shiki-themes.js +115 -0
- package/dist/lib/shiki.d.ts +101 -0
- package/dist/lib/shiki.js +353 -0
- package/dist/lib/shortcut-context.d.ts +23 -0
- package/dist/lib/shortcut-context.js +29 -0
- package/dist/lib/text.d.ts +56 -0
- package/dist/lib/text.js +149 -0
- package/dist/lib/theme-scope.d.ts +18 -0
- package/dist/lib/theme-scope.js +24 -0
- package/dist/lib/theme.d.ts +40 -0
- package/dist/lib/theme.js +96 -0
- package/dist/lib/tree-keys.d.ts +72 -0
- package/dist/lib/tree-keys.js +126 -0
- package/dist/lib/url.d.ts +17 -0
- package/dist/lib/url.js +38 -0
- package/dist/lib/use-copy-to-clipboard.d.ts +13 -0
- package/dist/lib/use-copy-to-clipboard.js +40 -0
- package/dist/lib/use-delayed-loading.d.ts +11 -0
- package/dist/lib/use-delayed-loading.js +31 -0
- package/dist/lib/use-file-picker.d.ts +26 -0
- package/dist/lib/use-file-picker.js +43 -0
- package/dist/lib/use-hydrated.d.ts +6 -0
- package/dist/lib/use-hydrated.js +11 -0
- package/dist/lib/use-latest.d.ts +12 -0
- package/dist/lib/use-latest.js +22 -0
- package/dist/lib/use-resolved-theme.d.ts +12 -0
- package/dist/lib/use-resolved-theme.js +25 -0
- package/dist/styles/base.css +393 -0
- package/dist/styles/code.css +35 -0
- package/dist/styles/fonts.css +12 -0
- package/dist/styles/index.css +13 -0
- package/dist/styles/tokens.gen.css +494 -0
- package/package.json +208 -4
package/dist/lib/text.js
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text measured and shaped the way people read it: in graphemes (`Intl.Segmenter`), so a cut never
|
|
3
|
+
* splits an emoji, a flag or a letter with its accents, in any script; cased, sorted and listed in the
|
|
4
|
+
* reader's locale. Each `Intl` object is built once per locale and options and kept, keyed by them, so
|
|
5
|
+
* one process can serve several locales.
|
|
6
|
+
*/
|
|
7
|
+
const segmenters = new Map();
|
|
8
|
+
const collators = new Map();
|
|
9
|
+
const listFormats = new Map();
|
|
10
|
+
function once(cache, key, make) {
|
|
11
|
+
let value = cache.get(key);
|
|
12
|
+
if (value === undefined) {
|
|
13
|
+
value = make();
|
|
14
|
+
cache.set(key, value);
|
|
15
|
+
}
|
|
16
|
+
return value;
|
|
17
|
+
}
|
|
18
|
+
/** Grapheme boundaries don't vary by locale today; the locale only keys the segmenter. */
|
|
19
|
+
function graphemes(locale) {
|
|
20
|
+
return once(segmenters, locale ?? "", () => new Intl.Segmenter(locale, { granularity: "grapheme" }));
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* How many words the text has, as its language divides them (`Intl.Segmenter`, granularity
|
|
24
|
+
* `word`): Chinese or Japanese, written without spaces, count word by word too.
|
|
25
|
+
*/
|
|
26
|
+
export function wordCount(text, locale) {
|
|
27
|
+
const words = once(segmenters, `${locale ?? ""} word`, () => new Intl.Segmenter(locale, { granularity: "word" }));
|
|
28
|
+
let count = 0;
|
|
29
|
+
for (const segment of words.segment(text))
|
|
30
|
+
if (segment.isWordLike === true)
|
|
31
|
+
count += 1;
|
|
32
|
+
return count;
|
|
33
|
+
}
|
|
34
|
+
/** The first grapheme of `text` ("é" of "élan", "👩💻" of "👩💻 dev"); empty for empty text. */
|
|
35
|
+
function firstGrapheme(text, locale) {
|
|
36
|
+
return graphemes(locale).segment(text).containing(0)?.segment ?? "";
|
|
37
|
+
}
|
|
38
|
+
/** The text's graphemes, the characters a reader sees ("é" and "👍🏽" are one each). */
|
|
39
|
+
export function graphemesOf(text, locale) {
|
|
40
|
+
return Array.from(graphemes(locale).segment(text), ({ segment }) => segment);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* The text cut to at most `max` graphemes, an ellipsis standing in for the rest. It reads at most
|
|
44
|
+
* `max + 1` graphemes, however long the text.
|
|
45
|
+
*/
|
|
46
|
+
export function truncateText(text, max, locale) {
|
|
47
|
+
// Every grapheme is at least one UTF-16 unit: text this short can't be over.
|
|
48
|
+
if (text.length <= max)
|
|
49
|
+
return text;
|
|
50
|
+
let count = 0;
|
|
51
|
+
let keep = 0;
|
|
52
|
+
for (const { index } of graphemes(locale).segment(text)) {
|
|
53
|
+
count += 1;
|
|
54
|
+
if (count === max)
|
|
55
|
+
keep = index;
|
|
56
|
+
else if (count > max)
|
|
57
|
+
return `${text.slice(0, keep).trimEnd()}…`;
|
|
58
|
+
}
|
|
59
|
+
return text;
|
|
60
|
+
}
|
|
61
|
+
/** The text's first `count` graphemes ("Éla" of "Élan" for 3); it reads no further than that. */
|
|
62
|
+
export function graphemePrefix(text, count, locale) {
|
|
63
|
+
let taken = 0;
|
|
64
|
+
for (const { index } of graphemes(locale).segment(text)) {
|
|
65
|
+
if (taken === count)
|
|
66
|
+
return text.slice(0, index);
|
|
67
|
+
taken += 1;
|
|
68
|
+
}
|
|
69
|
+
return text;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* The longest start of the text within `max` UTF-16 units that ends between graphemes: typed text
|
|
73
|
+
* kept to a cap a schema counts in `.length`, never splitting an emoji or a letter from its accents.
|
|
74
|
+
* No ellipsis: it is what the person typed, shortened.
|
|
75
|
+
*/
|
|
76
|
+
export function clipToLength(text, max, locale) {
|
|
77
|
+
if (text.length <= max)
|
|
78
|
+
return text;
|
|
79
|
+
let end = 0;
|
|
80
|
+
for (const { index, segment } of graphemes(locale).segment(text)) {
|
|
81
|
+
if (index + segment.length > max)
|
|
82
|
+
break;
|
|
83
|
+
end = index + segment.length;
|
|
84
|
+
}
|
|
85
|
+
return text.slice(0, end);
|
|
86
|
+
}
|
|
87
|
+
/** A collator for `locale`: lists people read sort in their own language's order. */
|
|
88
|
+
export function collatorFor(locale, options = {}) {
|
|
89
|
+
return once(collators, `${locale} ${JSON.stringify(options)}`, () => new Intl.Collator(locale, options));
|
|
90
|
+
}
|
|
91
|
+
/** Items as one phrase in a locale's list pattern ("a, b and c", "a, b or c"), as the catalogues join them. */
|
|
92
|
+
export function formatList(items, locale, type = "conjunction") {
|
|
93
|
+
return once(listFormats, `${locale} ${type}`, () => new Intl.ListFormat(locale, { type })).format(items);
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* The parts of one status as a screen reader says them ("Running, rate limited, 1 minute"): the
|
|
97
|
+
* language's own list separator and no "and" ("a、b、c" in Japanese). It's the narrow conjunction
|
|
98
|
+
* pattern, which every ICU build separates; the `unit` pattern lost its Russian commas in newer CLDR.
|
|
99
|
+
*/
|
|
100
|
+
export function spokenParts(items, locale) {
|
|
101
|
+
return once(listFormats, `${locale} spoken`, () => new Intl.ListFormat(locale, { type: "conjunction", style: "narrow" })).format(items);
|
|
102
|
+
}
|
|
103
|
+
/** Upper-cases the first grapheme for `locale` ("élan" → "Élan"); the rest stays as it is. */
|
|
104
|
+
export function capitalize(text, locale) {
|
|
105
|
+
const first = firstGrapheme(text, locale);
|
|
106
|
+
return `${first.toLocaleUpperCase(locale)}${text.slice(first.length)}`;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* An identifier's words, lower-cased for `locale`: camelCase, kebab-case, snake_case and dotted ids
|
|
110
|
+
* split ("repoName", "repo-name", "repo_name" → "repo name"; "parseHTMLFile" → "parse html file").
|
|
111
|
+
*/
|
|
112
|
+
export function idWords(id, locale) {
|
|
113
|
+
return id
|
|
114
|
+
.replaceAll(/([\p{Ll}\p{N}])(\p{Lu})/gu, "$1 $2")
|
|
115
|
+
.replaceAll(/(\p{Lu})(\p{Lu}\p{Ll})/gu, "$1 $2")
|
|
116
|
+
.replaceAll(/[\s_.-]+/gu, " ")
|
|
117
|
+
.trim()
|
|
118
|
+
.toLocaleLowerCase(locale);
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* An identifier as words in sentence case, for a label it doesn't have: "spendByDay" → "Spend by
|
|
122
|
+
* day", "team_invite" → "Team invite". The id itself when it has no words.
|
|
123
|
+
*/
|
|
124
|
+
export function humanizeId(id, locale) {
|
|
125
|
+
const words = idWords(id, locale);
|
|
126
|
+
return words === "" ? id : capitalize(words, locale);
|
|
127
|
+
}
|
|
128
|
+
/** A word for initials: it starts with a letter or digit, and apostrophes stay inside ("O’Brien"). */
|
|
129
|
+
const INITIAL_WORD = /[\p{L}\p{N}][\p{L}\p{M}\p{N}'’]*/gu;
|
|
130
|
+
/**
|
|
131
|
+
* Up to `max` initials of a name, upper-cased for `locale`: the first grapheme of its first word and
|
|
132
|
+
* of its last ones ("Ava Chen" and "Ava Maria Chen" → "AC", "kai-rivera" → "KR", "Milo" → "M",
|
|
133
|
+
* "émile zola" → "ÉZ"). Spaces and punctuation separate words. Empty when the name has none.
|
|
134
|
+
*/
|
|
135
|
+
export function initialsOf(name, locale, max = 2) {
|
|
136
|
+
const words = name.match(INITIAL_WORD) ?? [];
|
|
137
|
+
if (max <= 0 || words.length === 0)
|
|
138
|
+
return "";
|
|
139
|
+
const picked = words.length <= max ? words : [words[0] ?? "", ...words.slice(words.length - max + 1)];
|
|
140
|
+
return picked
|
|
141
|
+
.map((word) => firstGrapheme(word, locale))
|
|
142
|
+
.join("")
|
|
143
|
+
.toLocaleUpperCase(locale);
|
|
144
|
+
}
|
|
145
|
+
/** A file name's extension, lower case ("tsx"), or undefined for none (`Makefile`, `.env`, `notes.`). */
|
|
146
|
+
export function extensionOf(name) {
|
|
147
|
+
const dot = name.lastIndexOf(".");
|
|
148
|
+
return dot <= 0 || dot === name.length - 1 ? undefined : name.slice(dot + 1).toLowerCase();
|
|
149
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type ComponentProps } from "react";
|
|
2
|
+
import type { ResolvedTheme } from "./theme.js";
|
|
3
|
+
/** Where the kit's popups mount, for a Base UI `Portal`'s `container`. */
|
|
4
|
+
export declare function usePortalContainer(): HTMLElement | null | undefined;
|
|
5
|
+
export interface ThemeScopeProps extends Omit<ComponentProps<"div">, "children"> {
|
|
6
|
+
/** The theme everything inside takes, popups included. */
|
|
7
|
+
readonly theme: ResolvedTheme;
|
|
8
|
+
readonly children?: ComponentProps<"div">["children"];
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* A part of the page in one theme, whatever the page's: its `data-theme` reaches what it renders, and
|
|
12
|
+
* the popovers, menus, selects, tooltips, dialogs, sheets and toasts opened inside it mount in a host
|
|
13
|
+
* inside the scope, so they take its theme too. Two scopes side by side show the same thing in both
|
|
14
|
+
* themes. The host is the scope's last child, after its content. Popups position `fixed`, so keep a
|
|
15
|
+
* scope out from under an element with `transform`, `filter` or `contain` (they'd re-anchor or clip
|
|
16
|
+
* them). It makes no stacking context: the layers (`--z-*`) stack as they do anywhere else.
|
|
17
|
+
*/
|
|
18
|
+
export declare function ThemeScope({ theme, children, ...props }: ThemeScopeProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { createContext, use, useState } from "react";
|
|
4
|
+
/**
|
|
5
|
+
* The host popups mount in: inside a `ThemeScope`, its host (`null` until it's in the document, which
|
|
6
|
+
* makes Base UI wait for it); outside one, `undefined`, which Base UI reads as the document's body.
|
|
7
|
+
*/
|
|
8
|
+
const PortalContainerContext = createContext(undefined);
|
|
9
|
+
/** Where the kit's popups mount, for a Base UI `Portal`'s `container`. */
|
|
10
|
+
export function usePortalContainer() {
|
|
11
|
+
return use(PortalContainerContext);
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* A part of the page in one theme, whatever the page's: its `data-theme` reaches what it renders, and
|
|
15
|
+
* the popovers, menus, selects, tooltips, dialogs, sheets and toasts opened inside it mount in a host
|
|
16
|
+
* inside the scope, so they take its theme too. Two scopes side by side show the same thing in both
|
|
17
|
+
* themes. The host is the scope's last child, after its content. Popups position `fixed`, so keep a
|
|
18
|
+
* scope out from under an element with `transform`, `filter` or `contain` (they'd re-anchor or clip
|
|
19
|
+
* them). It makes no stacking context: the layers (`--z-*`) stack as they do anywhere else.
|
|
20
|
+
*/
|
|
21
|
+
export function ThemeScope({ theme, children, ...props }) {
|
|
22
|
+
const [host, setHost] = useState(null);
|
|
23
|
+
return (_jsxs("div", { "data-slot": "theme-scope", "data-theme": theme, ...props, children: [_jsx(PortalContainerContext, { value: host, children: children }), _jsx("div", { "data-slot": "theme-scope-portal", ref: setHost })] }));
|
|
24
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
export { useResolvedTheme } from "./use-resolved-theme.js";
|
|
3
|
+
export type ThemePreference = "light" | "dark" | "system";
|
|
4
|
+
export type ResolvedTheme = "light" | "dark";
|
|
5
|
+
/**
|
|
6
|
+
* The Web Storage key the reader's choice is kept under, unless the host names its own (`storageKey`):
|
|
7
|
+
* the kit's own, so it can't meet a key of the host's.
|
|
8
|
+
*/
|
|
9
|
+
export declare const THEME_STORAGE_KEY = "ui-theme";
|
|
10
|
+
interface ThemeContextValue {
|
|
11
|
+
preference: ThemePreference;
|
|
12
|
+
resolved: ResolvedTheme;
|
|
13
|
+
setPreference: (next: ThemePreference) => void;
|
|
14
|
+
}
|
|
15
|
+
export interface ThemeProviderProps {
|
|
16
|
+
children: ReactNode;
|
|
17
|
+
defaultPreference?: ThemePreference;
|
|
18
|
+
/** The Web Storage key the choice is kept under, as given; `THEME_STORAGE_KEY` when left out. */
|
|
19
|
+
storageKey?: string;
|
|
20
|
+
/** Element receiving `data-theme`; defaults to <html>. */
|
|
21
|
+
target?: () => HTMLElement;
|
|
22
|
+
/**
|
|
23
|
+
* Also set `light` / `dark` as a class on the target, for libraries keyed on `.dark` (Fumadocs on
|
|
24
|
+
* the site). Pass the same flag to `themeInitScript`.
|
|
25
|
+
*/
|
|
26
|
+
themeClass?: boolean;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Resolves light/dark/system and writes `data-theme` + `color-scheme` on <html>. Pair with
|
|
30
|
+
* `themeInitScript` in the document head to avoid a flash before hydration.
|
|
31
|
+
*/
|
|
32
|
+
export declare function ThemeProvider({ children, defaultPreference, storageKey, target, themeClass, }: ThemeProviderProps): import("react").JSX.Element;
|
|
33
|
+
export declare function useTheme(): ThemeContextValue;
|
|
34
|
+
/**
|
|
35
|
+
* Inline in <head> before styles load: applies the stored theme before first paint. `themeClass`
|
|
36
|
+
* matches the provider's option.
|
|
37
|
+
*/
|
|
38
|
+
export declare const themeInitScript: (storageKey?: string, { themeClass }?: {
|
|
39
|
+
themeClass?: boolean;
|
|
40
|
+
}) => string;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { createContext, use, useCallback, useEffect, useMemo, useState, useSyncExternalStore, } from "react";
|
|
4
|
+
export { useResolvedTheme } from "./use-resolved-theme.js";
|
|
5
|
+
/**
|
|
6
|
+
* The Web Storage key the reader's choice is kept under, unless the host names its own (`storageKey`):
|
|
7
|
+
* the kit's own, so it can't meet a key of the host's.
|
|
8
|
+
*/
|
|
9
|
+
export const THEME_STORAGE_KEY = "ui-theme";
|
|
10
|
+
const DARK_QUERY = "(prefers-color-scheme: dark)";
|
|
11
|
+
function subscribeSystem(onChange) {
|
|
12
|
+
const mql = window.matchMedia(DARK_QUERY);
|
|
13
|
+
mql.addEventListener("change", onChange);
|
|
14
|
+
return () => mql.removeEventListener("change", onChange);
|
|
15
|
+
}
|
|
16
|
+
const getSystem = () => (window.matchMedia(DARK_QUERY).matches ? "dark" : "light");
|
|
17
|
+
const getServerSystem = () => "light";
|
|
18
|
+
/** The OS color scheme, live. */
|
|
19
|
+
function useSystemTheme() {
|
|
20
|
+
return useSyncExternalStore(subscribeSystem, getSystem, getServerSystem);
|
|
21
|
+
}
|
|
22
|
+
function isPreference(value) {
|
|
23
|
+
return value === "light" || value === "dark" || value === "system";
|
|
24
|
+
}
|
|
25
|
+
function readStored(storageKey, fallback) {
|
|
26
|
+
try {
|
|
27
|
+
const raw = window.localStorage.getItem(storageKey);
|
|
28
|
+
return isPreference(raw) ? raw : fallback;
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return fallback;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
const ThemeContext = createContext(null);
|
|
35
|
+
/**
|
|
36
|
+
* Resolves light/dark/system and writes `data-theme` + `color-scheme` on <html>. Pair with
|
|
37
|
+
* `themeInitScript` in the document head to avoid a flash before hydration.
|
|
38
|
+
*/
|
|
39
|
+
export function ThemeProvider({ children, defaultPreference = "system", storageKey = THEME_STORAGE_KEY, target = () => document.documentElement, themeClass = false, }) {
|
|
40
|
+
const [preference, setPreferenceState] = useState(() => typeof window === "undefined" ? defaultPreference : readStored(storageKey, defaultPreference));
|
|
41
|
+
const system = useSystemTheme();
|
|
42
|
+
const resolved = preference === "system" ? system : preference;
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
const el = target();
|
|
45
|
+
// Already painted in this theme (by `themeInitScript`, or an earlier run): touching the root
|
|
46
|
+
// would restyle the whole document for nothing.
|
|
47
|
+
const applied = el.getAttribute("data-theme") === resolved && (!themeClass || el.classList.contains(resolved));
|
|
48
|
+
if (applied)
|
|
49
|
+
return undefined;
|
|
50
|
+
// Suppress transitions during the swap so every surface flips in the same frame.
|
|
51
|
+
el.setAttribute("data-theme-switching", "");
|
|
52
|
+
el.setAttribute("data-theme", resolved);
|
|
53
|
+
el.style.colorScheme = resolved;
|
|
54
|
+
if (themeClass) {
|
|
55
|
+
el.classList.remove(resolved === "dark" ? "light" : "dark");
|
|
56
|
+
el.classList.add(resolved);
|
|
57
|
+
}
|
|
58
|
+
let inner = 0;
|
|
59
|
+
const outer = requestAnimationFrame(() => {
|
|
60
|
+
inner = requestAnimationFrame(() => el.removeAttribute("data-theme-switching"));
|
|
61
|
+
});
|
|
62
|
+
// A run cut short (Strict Mode's second run, a theme changing again within two frames) clears the flag too:
|
|
63
|
+
// the next run finds the theme already applied and sets nothing, so a flag left on would keep every
|
|
64
|
+
// transition off for good.
|
|
65
|
+
return () => {
|
|
66
|
+
cancelAnimationFrame(outer);
|
|
67
|
+
cancelAnimationFrame(inner);
|
|
68
|
+
el.removeAttribute("data-theme-switching");
|
|
69
|
+
};
|
|
70
|
+
}, [resolved, target, themeClass]);
|
|
71
|
+
const setPreference = useCallback((next) => {
|
|
72
|
+
setPreferenceState(next);
|
|
73
|
+
try {
|
|
74
|
+
window.localStorage.setItem(storageKey, next);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
// Storage can be unavailable (private mode, sandboxed iframes); the choice still applies.
|
|
78
|
+
}
|
|
79
|
+
}, [storageKey]);
|
|
80
|
+
const value = useMemo(() => ({ preference, resolved, setPreference }), [preference, resolved, setPreference]);
|
|
81
|
+
return _jsx(ThemeContext, { value: value, children: children });
|
|
82
|
+
}
|
|
83
|
+
export function useTheme() {
|
|
84
|
+
const ctx = use(ThemeContext);
|
|
85
|
+
if (!ctx)
|
|
86
|
+
throw new Error("useTheme must be used inside <ThemeProvider>");
|
|
87
|
+
return ctx;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Inline in <head> before styles load: applies the stored theme before first paint. `themeClass`
|
|
91
|
+
* matches the provider's option.
|
|
92
|
+
*/
|
|
93
|
+
export const themeInitScript = (storageKey = THEME_STORAGE_KEY, { themeClass = false } = {}) => {
|
|
94
|
+
const classes = themeClass ? `e.classList.add(d?"dark":"light");` : "";
|
|
95
|
+
return `(()=>{try{var p=localStorage.getItem(${JSON.stringify(storageKey)});var d=p==="dark"||((p===null||p==="system")&&matchMedia("${DARK_QUERY}").matches);var e=document.documentElement;e.dataset.theme=d?"dark":"light";e.style.colorScheme=d?"dark":"light";${classes}}catch(_){}})()`;
|
|
96
|
+
};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The WAI-ARIA tree's keyboard, once for every tree: the run graph, the files pane and the browser's
|
|
3
|
+
* elements panel. ↑/↓ move, → opens a row or enters it, ← closes it or goes to its parent, Home and
|
|
4
|
+
* End go to the ends, Enter and Space activate, `*` opens every sibling, and typing a name jumps to
|
|
5
|
+
* it. The trees differ only in how they show focus (a roving tab stop, or `aria-activedescendant` on
|
|
6
|
+
* a windowed list) and in what activating a row does, so this hands each tree a move to apply.
|
|
7
|
+
*/
|
|
8
|
+
import { type KeyboardEvent } from "react";
|
|
9
|
+
/**
|
|
10
|
+
* One row the tree shows, as the keyboard sees it. Rows are listed depth-first, and a collapsed row's
|
|
11
|
+
* descendants are left out.
|
|
12
|
+
*/
|
|
13
|
+
export interface TreeKeyRow {
|
|
14
|
+
readonly id: string;
|
|
15
|
+
/** The parent row's id; undefined at the top level. */
|
|
16
|
+
readonly parentId: string | undefined;
|
|
17
|
+
/** It has children, loaded or not. */
|
|
18
|
+
readonly expandable: boolean;
|
|
19
|
+
readonly expanded: boolean;
|
|
20
|
+
}
|
|
21
|
+
/** What a key does to the tree: the tree applies it. */
|
|
22
|
+
export type TreeMove = {
|
|
23
|
+
readonly type: "focus";
|
|
24
|
+
readonly id: string;
|
|
25
|
+
} | {
|
|
26
|
+
readonly type: "toggle";
|
|
27
|
+
readonly id: string;
|
|
28
|
+
readonly expanded: boolean;
|
|
29
|
+
} | {
|
|
30
|
+
readonly type: "expand-siblings";
|
|
31
|
+
readonly ids: readonly string[];
|
|
32
|
+
}
|
|
33
|
+
/** Enter or Space: the row's own action (select a run, open a file, choose an element). */
|
|
34
|
+
| {
|
|
35
|
+
readonly type: "activate";
|
|
36
|
+
readonly id: string;
|
|
37
|
+
} | {
|
|
38
|
+
readonly type: "none";
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* The move for a navigation key on the focused row. `key` is logical (`logicalArrow`): ArrowRight
|
|
42
|
+
* points to the end of the line. With nothing focused, ↓ and Home start at the top, ↑ and End at the
|
|
43
|
+
* bottom.
|
|
44
|
+
*/
|
|
45
|
+
export declare function treeKeyMove(rows: readonly TreeKeyRow[], focusedId: string | undefined, key: string): TreeMove;
|
|
46
|
+
/**
|
|
47
|
+
* Type-ahead: the next row whose name starts with what was typed, after the focused one and wrapping
|
|
48
|
+
* around. Names compare as the reader's language compares letters, case and accents aside where it
|
|
49
|
+
* treats them as the same letter ("e" finds "Élan"; in Swedish "a" doesn't find "Äpple"), a
|
|
50
|
+
* character at a time ("👍🏽" is one). The same character typed again moves on to the next row it
|
|
51
|
+
* starts; a longer prefix stays on the focused row while that still matches.
|
|
52
|
+
*/
|
|
53
|
+
export declare function treeTypeAhead<Row extends TreeKeyRow>(rows: readonly Row[], focusedId: string | undefined, typed: string, labelOf: (row: Row) => string, locale: string): TreeMove;
|
|
54
|
+
export interface TreeKeysOptions<Row extends TreeKeyRow> {
|
|
55
|
+
/** The rows the tree shows, in order. */
|
|
56
|
+
readonly rows: readonly Row[];
|
|
57
|
+
/** The row with the keyboard focus (or the active descendant); undefined before there is one. */
|
|
58
|
+
readonly focusedId: string | undefined;
|
|
59
|
+
/** The name type-ahead matches: what the row shows. */
|
|
60
|
+
readonly labelOf: (row: Row) => string;
|
|
61
|
+
/** Applies a move; the key's default action is already prevented. */
|
|
62
|
+
readonly onMove: (move: Exclude<TreeMove, {
|
|
63
|
+
readonly type: "none";
|
|
64
|
+
}>) => void;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* The tree's key handler: put it on the focused element's `onKeyDown` (each row, or the tree when it
|
|
68
|
+
* uses `aria-activedescendant`). Arrows follow the reading direction (`useInlineSign()`), type-ahead
|
|
69
|
+
* the locale (`useUiLocale()`). Keys with ⌘, Ctrl or Alt, and keys an input method is composing, pass
|
|
70
|
+
* through.
|
|
71
|
+
*/
|
|
72
|
+
export declare function useTreeKeys<Row extends TreeKeyRow>({ rows, focusedId, labelOf, onMove, }: TreeKeysOptions<Row>): (event: KeyboardEvent) => void;
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
/**
|
|
3
|
+
* The WAI-ARIA tree's keyboard, once for every tree: the run graph, the files pane and the browser's
|
|
4
|
+
* elements panel. ↑/↓ move, → opens a row or enters it, ← closes it or goes to its parent, Home and
|
|
5
|
+
* End go to the ends, Enter and Space activate, `*` opens every sibling, and typing a name jumps to
|
|
6
|
+
* it. The trees differ only in how they show focus (a roving tab stop, or `aria-activedescendant` on
|
|
7
|
+
* a windowed list) and in what activating a row does, so this hands each tree a move to apply.
|
|
8
|
+
*/
|
|
9
|
+
import { useRef } from "react";
|
|
10
|
+
import { useUiLocale } from "../i18n/provider.js";
|
|
11
|
+
import { logicalArrow, useInlineSign } from "./direction.js";
|
|
12
|
+
import { isComposing } from "./keys.js";
|
|
13
|
+
import { collatorFor, graphemePrefix, graphemesOf } from "./text.js";
|
|
14
|
+
const NONE = { type: "none" };
|
|
15
|
+
/**
|
|
16
|
+
* The move for a navigation key on the focused row. `key` is logical (`logicalArrow`): ArrowRight
|
|
17
|
+
* points to the end of the line. With nothing focused, ↓ and Home start at the top, ↑ and End at the
|
|
18
|
+
* bottom.
|
|
19
|
+
*/
|
|
20
|
+
export function treeKeyMove(rows, focusedId, key) {
|
|
21
|
+
const index = rows.findIndex((r) => r.id === focusedId);
|
|
22
|
+
const to = (i) => {
|
|
23
|
+
const target = rows[i];
|
|
24
|
+
return target === undefined || target.id === focusedId ? NONE : { type: "focus", id: target.id };
|
|
25
|
+
};
|
|
26
|
+
const row = rows[index];
|
|
27
|
+
if (row === undefined) {
|
|
28
|
+
if (key === "ArrowDown" || key === "Home")
|
|
29
|
+
return to(0);
|
|
30
|
+
if (key === "ArrowUp" || key === "End")
|
|
31
|
+
return to(rows.length - 1);
|
|
32
|
+
return NONE;
|
|
33
|
+
}
|
|
34
|
+
switch (key) {
|
|
35
|
+
case "ArrowDown":
|
|
36
|
+
return to(index + 1);
|
|
37
|
+
case "ArrowUp":
|
|
38
|
+
return to(index - 1);
|
|
39
|
+
case "Home":
|
|
40
|
+
return to(0);
|
|
41
|
+
case "End":
|
|
42
|
+
return to(rows.length - 1);
|
|
43
|
+
case "ArrowRight":
|
|
44
|
+
if (!row.expandable)
|
|
45
|
+
return NONE;
|
|
46
|
+
if (!row.expanded)
|
|
47
|
+
return { type: "toggle", id: row.id, expanded: true };
|
|
48
|
+
// Open, with its children shown (they may still be loading).
|
|
49
|
+
return rows[index + 1]?.parentId === row.id ? to(index + 1) : NONE;
|
|
50
|
+
case "ArrowLeft":
|
|
51
|
+
if (row.expandable && row.expanded)
|
|
52
|
+
return { type: "toggle", id: row.id, expanded: false };
|
|
53
|
+
return to(rows.findIndex((r) => r.id === row.parentId));
|
|
54
|
+
case "Enter":
|
|
55
|
+
case " ":
|
|
56
|
+
return { type: "activate", id: row.id };
|
|
57
|
+
case "*": {
|
|
58
|
+
const ids = rows
|
|
59
|
+
.filter((r) => r.parentId === row.parentId && r.expandable && !r.expanded)
|
|
60
|
+
.map((r) => r.id);
|
|
61
|
+
return ids.length === 0 ? NONE : { type: "expand-siblings", ids };
|
|
62
|
+
}
|
|
63
|
+
default:
|
|
64
|
+
return NONE;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Type-ahead: the next row whose name starts with what was typed, after the focused one and wrapping
|
|
69
|
+
* around. Names compare as the reader's language compares letters, case and accents aside where it
|
|
70
|
+
* treats them as the same letter ("e" finds "Élan"; in Swedish "a" doesn't find "Äpple"), a
|
|
71
|
+
* character at a time ("👍🏽" is one). The same character typed again moves on to the next row it
|
|
72
|
+
* starts; a longer prefix stays on the focused row while that still matches.
|
|
73
|
+
*/
|
|
74
|
+
export function treeTypeAhead(rows, focusedId, typed, labelOf, locale) {
|
|
75
|
+
const characters = graphemesOf(typed, locale);
|
|
76
|
+
const first = characters[0];
|
|
77
|
+
if (first === undefined || rows.length === 0)
|
|
78
|
+
return NONE;
|
|
79
|
+
const repeated = characters.every((c) => c === first);
|
|
80
|
+
const needle = repeated ? first : typed;
|
|
81
|
+
const length = repeated ? 1 : characters.length;
|
|
82
|
+
const collator = collatorFor(locale, { usage: "search", sensitivity: "base" });
|
|
83
|
+
const index = rows.findIndex((r) => r.id === focusedId);
|
|
84
|
+
const start = index === -1 ? 0 : index + (repeated ? 1 : 0);
|
|
85
|
+
for (let step = 0; step < rows.length; step++) {
|
|
86
|
+
const row = rows[(start + step) % rows.length];
|
|
87
|
+
if (row !== undefined && collator.compare(graphemePrefix(labelOf(row), length, locale), needle) === 0) {
|
|
88
|
+
return row.id === focusedId ? NONE : { type: "focus", id: row.id };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return NONE;
|
|
92
|
+
}
|
|
93
|
+
/** How long typed characters add up to one search: the kit's menus wait as long. */
|
|
94
|
+
const TYPE_AHEAD_MS = 500;
|
|
95
|
+
/**
|
|
96
|
+
* The tree's key handler: put it on the focused element's `onKeyDown` (each row, or the tree when it
|
|
97
|
+
* uses `aria-activedescendant`). Arrows follow the reading direction (`useInlineSign()`), type-ahead
|
|
98
|
+
* the locale (`useUiLocale()`). Keys with ⌘, Ctrl or Alt, and keys an input method is composing, pass
|
|
99
|
+
* through.
|
|
100
|
+
*/
|
|
101
|
+
export function useTreeKeys({ rows, focusedId, labelOf, onMove, }) {
|
|
102
|
+
const inline = useInlineSign();
|
|
103
|
+
const locale = useUiLocale();
|
|
104
|
+
const typed = useRef({ text: "", at: 0 });
|
|
105
|
+
return (event) => {
|
|
106
|
+
if (event.altKey || event.ctrlKey || event.metaKey || isComposing(event))
|
|
107
|
+
return;
|
|
108
|
+
const now = performance.now();
|
|
109
|
+
const buffer = now - typed.current.at < TYPE_AHEAD_MS ? typed.current.text : "";
|
|
110
|
+
// A character searches; a space only once a search has begun (names have spaces), `*` never.
|
|
111
|
+
const searching = graphemesOf(event.key).length === 1 && event.key !== "*" && (event.key !== " " || buffer !== "");
|
|
112
|
+
let move;
|
|
113
|
+
if (searching) {
|
|
114
|
+
typed.current = { text: buffer + event.key, at: now };
|
|
115
|
+
move = treeTypeAhead(rows, focusedId, typed.current.text, labelOf, locale);
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
typed.current = { text: "", at: 0 };
|
|
119
|
+
move = treeKeyMove(rows, focusedId, logicalArrow(event.key, inline));
|
|
120
|
+
}
|
|
121
|
+
if (move.type === "none")
|
|
122
|
+
return;
|
|
123
|
+
event.preventDefault();
|
|
124
|
+
onMove(move);
|
|
125
|
+
};
|
|
126
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** A URL's exact host, port and `www.` kept ("www.example.com:8443"); undefined when the text isn't a URL. */
|
|
2
|
+
export declare function urlHost(url: string): string | undefined;
|
|
3
|
+
/**
|
|
4
|
+
* The exact host a page came from (`www.` kept), for a notice about what a site may do; text that
|
|
5
|
+
* isn't a URL comes back as it was.
|
|
6
|
+
*/
|
|
7
|
+
export declare function exactHostOf(url: string): string;
|
|
8
|
+
/**
|
|
9
|
+
* The site a link points at, as people say it: its host (a port stays) without a leading `www.`.
|
|
10
|
+
* Text that isn't a URL comes back as it was.
|
|
11
|
+
*/
|
|
12
|
+
export declare function hostOf(url: string): string;
|
|
13
|
+
/**
|
|
14
|
+
* A link a remote party chose (a Hub listing's homepage, a search result) when it is a web address:
|
|
15
|
+
* http or https, else undefined, so a `file:`, `data:` or custom-scheme link is never rendered.
|
|
16
|
+
*/
|
|
17
|
+
export declare function webUrl(text: string | undefined): string | undefined;
|
package/dist/lib/url.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** A URL's exact host, port and `www.` kept ("www.example.com:8443"); undefined when the text isn't a URL. */
|
|
2
|
+
export function urlHost(url) {
|
|
3
|
+
try {
|
|
4
|
+
return new URL(url).host;
|
|
5
|
+
}
|
|
6
|
+
catch {
|
|
7
|
+
return undefined;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* The exact host a page came from (`www.` kept), for a notice about what a site may do; text that
|
|
12
|
+
* isn't a URL comes back as it was.
|
|
13
|
+
*/
|
|
14
|
+
export function exactHostOf(url) {
|
|
15
|
+
return urlHost(url) ?? url;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* The site a link points at, as people say it: its host (a port stays) without a leading `www.`.
|
|
19
|
+
* Text that isn't a URL comes back as it was.
|
|
20
|
+
*/
|
|
21
|
+
export function hostOf(url) {
|
|
22
|
+
return urlHost(url)?.replace(/^www\./u, "") ?? url;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* A link a remote party chose (a Hub listing's homepage, a search result) when it is a web address:
|
|
26
|
+
* http or https, else undefined, so a `file:`, `data:` or custom-scheme link is never rendered.
|
|
27
|
+
*/
|
|
28
|
+
export function webUrl(text) {
|
|
29
|
+
if (text === undefined)
|
|
30
|
+
return undefined;
|
|
31
|
+
try {
|
|
32
|
+
const url = new URL(text);
|
|
33
|
+
return url.protocol === "https:" || url.protocol === "http:" ? url.href : undefined;
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
/** Writes text to the system clipboard. */
|
|
3
|
+
export type ClipboardWrite = (text: string) => Promise<void>;
|
|
4
|
+
/**
|
|
5
|
+
* The host's clipboard for every copy control below it (the desktop shell's writes without the
|
|
6
|
+
* browser's permission prompt); without one, the browser's.
|
|
7
|
+
*/
|
|
8
|
+
export declare function ClipboardProvider({ write, children, }: {
|
|
9
|
+
readonly write: ClipboardWrite;
|
|
10
|
+
readonly children: ReactNode;
|
|
11
|
+
}): import("react").JSX.Element;
|
|
12
|
+
/** Clipboard write with a transient "copied" flag (resets after `resetMs`). */
|
|
13
|
+
export declare function useCopyToClipboard(resetMs?: number): readonly [boolean, (text: string) => void];
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { createContext, use, useCallback, useEffect, useRef, useState } from "react";
|
|
4
|
+
const browserWrite = (text) => navigator.clipboard.writeText(text);
|
|
5
|
+
const ClipboardContext = createContext(browserWrite);
|
|
6
|
+
/**
|
|
7
|
+
* The host's clipboard for every copy control below it (the desktop shell's writes without the
|
|
8
|
+
* browser's permission prompt); without one, the browser's.
|
|
9
|
+
*/
|
|
10
|
+
export function ClipboardProvider({ write, children, }) {
|
|
11
|
+
return _jsx(ClipboardContext, { value: write, children: children });
|
|
12
|
+
}
|
|
13
|
+
/** The clipboard write the host provides. */
|
|
14
|
+
function useClipboardWrite() {
|
|
15
|
+
return use(ClipboardContext);
|
|
16
|
+
}
|
|
17
|
+
/** Clipboard write with a transient "copied" flag (resets after `resetMs`). */
|
|
18
|
+
export function useCopyToClipboard(resetMs = 1600) {
|
|
19
|
+
const write = useClipboardWrite();
|
|
20
|
+
const [copied, setCopied] = useState(false);
|
|
21
|
+
const timer = useRef(undefined);
|
|
22
|
+
useEffect(() => () => clearTimeout(timer.current), []);
|
|
23
|
+
const copy = useCallback((text) => {
|
|
24
|
+
if (typeof navigator === "undefined")
|
|
25
|
+
return;
|
|
26
|
+
const run = async () => {
|
|
27
|
+
try {
|
|
28
|
+
await write(text);
|
|
29
|
+
setCopied(true);
|
|
30
|
+
clearTimeout(timer.current);
|
|
31
|
+
timer.current = setTimeout(() => setCopied(false), resetMs);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
setCopied(false);
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
void run();
|
|
38
|
+
}, [resetMs, write]);
|
|
39
|
+
return [copied, copy];
|
|
40
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface DelayedLoadingOptions {
|
|
2
|
+
/** Wait this long before showing (ms). */
|
|
3
|
+
delay?: number;
|
|
4
|
+
/** Once shown, stay visible at least this long (ms). */
|
|
5
|
+
minVisible?: number;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Turns a raw `loading` flag into a flicker-free one: fast operations never flash a loader, and a
|
|
9
|
+
* loader that does appear stays long enough to read as intentional (Vercel's 150–300 / 300–500 ms rule).
|
|
10
|
+
*/
|
|
11
|
+
export declare function useDelayedLoading(loading: boolean, { delay, minVisible }?: DelayedLoadingOptions): boolean;
|