@domicile-desktop/component-library 0.0.0-alpha-32449d3c53bb → 0.0.0-alpha-a2def76ec0b8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
"dependencies": {
|
|
3
3
|
"@base-ui/react": "^1.8.0",
|
|
4
4
|
"@cprussin/option-result": "^2.0.1",
|
|
5
|
-
"@domicile-desktop/sdk": "0.0.0-alpha-
|
|
6
|
-
"@domicile-desktop/system-apps": "0.0.0-alpha-
|
|
5
|
+
"@domicile-desktop/sdk": "0.0.0-alpha-a2def76ec0b8",
|
|
6
|
+
"@domicile-desktop/system-apps": "0.0.0-alpha-a2def76ec0b8",
|
|
7
7
|
"@pandacss/dev": "^1.12.1",
|
|
8
8
|
"@phosphor-icons/react": "^2.1.10"
|
|
9
9
|
},
|
|
@@ -87,6 +87,7 @@
|
|
|
87
87
|
"./ListRow": "./src/ListRow/ListRow.tsx",
|
|
88
88
|
"./Loading": "./src/Loading/Loading.tsx",
|
|
89
89
|
"./list-walk": "./src/list-walk.ts",
|
|
90
|
+
"./Marked": "./src/ResultList/Marked.tsx",
|
|
90
91
|
"./ModalDialog": "./src/ModalDialog/ModalDialog.tsx",
|
|
91
92
|
"./media-theme-source": "./src/ThemeSwitch/media-theme-source.ts",
|
|
92
93
|
"./Notice": "./src/Notice/Notice.tsx",
|
|
@@ -102,6 +103,8 @@
|
|
|
102
103
|
"./pandacss-preset": "./src/pandacss-preset.ts",
|
|
103
104
|
"./QuickTile": "./src/QuickTile/QuickTile.tsx",
|
|
104
105
|
"./QuickToggle": "./src/QuickToggle/QuickToggle.tsx",
|
|
106
|
+
"./ResultList": "./src/ResultList/ResultList.tsx",
|
|
107
|
+
"./RowTile": "./src/ResultList/RowTile.tsx",
|
|
105
108
|
"./Screen": "./src/Screen/Screen.tsx",
|
|
106
109
|
"./Select": "./src/Select/Select.tsx",
|
|
107
110
|
"./SlideOver": "./src/SlideOver/SlideOver.tsx",
|
|
@@ -159,5 +162,5 @@
|
|
|
159
162
|
"test:unit": "bun test"
|
|
160
163
|
},
|
|
161
164
|
"type": "module",
|
|
162
|
-
"version": "0.0.0-alpha-
|
|
165
|
+
"version": "0.0.0-alpha-a2def76ec0b8"
|
|
163
166
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { Fragment } from "react";
|
|
2
|
+
|
|
3
|
+
import { css } from "../../styled-system/css";
|
|
4
|
+
import { marked } from "./match-runs";
|
|
5
|
+
|
|
6
|
+
type Props = {
|
|
7
|
+
query: string;
|
|
8
|
+
text: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A row's text with what `query` matched in bold, so users see why it
|
|
13
|
+
* matched. Uses `mark` for its semantics, which screen readers understand.
|
|
14
|
+
*/
|
|
15
|
+
export const Marked = ({ query, text }: Props) => (
|
|
16
|
+
<>
|
|
17
|
+
{marked(text, query).map((run, at) =>
|
|
18
|
+
run.matched ? (
|
|
19
|
+
// Runs have no identity beyond their position.
|
|
20
|
+
<mark className={markStyles} key={at}>
|
|
21
|
+
{run.text}
|
|
22
|
+
</mark>
|
|
23
|
+
) : (
|
|
24
|
+
<Fragment key={at}>{run.text}</Fragment>
|
|
25
|
+
),
|
|
26
|
+
)}
|
|
27
|
+
</>
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
// In the row's own color instead of `mark`'s yellow. Emboldened with a
|
|
31
|
+
// stroke rather than font weight, which would widen letters and shift the
|
|
32
|
+
// row on every key. `paint-order: stroke` keeps letter holes open.
|
|
33
|
+
const markStyles = css({
|
|
34
|
+
backgroundColor: "transparent",
|
|
35
|
+
color: "inherit",
|
|
36
|
+
paintOrder: "stroke",
|
|
37
|
+
WebkitTextStroke: "{borderWidths.fauxBold} currentColor",
|
|
38
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
import { flex, hstack } from "../../styled-system/patterns";
|
|
4
|
+
import { keepInView } from "../list-walk";
|
|
5
|
+
|
|
6
|
+
type Props<Item> = {
|
|
7
|
+
/** Draws an item's row, such as a `RowTile` and its name. */
|
|
8
|
+
children: (item: Item) => ReactNode;
|
|
9
|
+
/** The row lit by the keyboard or the pointer, if any. */
|
|
10
|
+
highlighted: number | undefined;
|
|
11
|
+
/** The listbox's ID, which the combobox that drives it names. */
|
|
12
|
+
id: string;
|
|
13
|
+
items: readonly Item[];
|
|
14
|
+
keyOf: (item: Item) => string;
|
|
15
|
+
/** Names the list, when no combobox names it. */
|
|
16
|
+
label?: string | undefined;
|
|
17
|
+
onChoose: (item: Item) => void;
|
|
18
|
+
/** The pointer moved over the row at `at`. */
|
|
19
|
+
onHighlight: (at: number) => void;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The rows under a search box, as in the launcher: one highlight, which the
|
|
24
|
+
* keyboard and the pointer both move, and a click to choose.
|
|
25
|
+
*
|
|
26
|
+
* A listbox driven by `aria-activedescendant`, so focus stays in the box.
|
|
27
|
+
* The box moves the highlight on keys; see `list-walk`. Name the highlighted
|
|
28
|
+
* row with {@link resultId}.
|
|
29
|
+
*/
|
|
30
|
+
export const ResultList = <Item,>({
|
|
31
|
+
children,
|
|
32
|
+
highlighted,
|
|
33
|
+
id,
|
|
34
|
+
items,
|
|
35
|
+
keyOf,
|
|
36
|
+
label,
|
|
37
|
+
onChoose,
|
|
38
|
+
onHighlight,
|
|
39
|
+
}: Props<Item>) => (
|
|
40
|
+
// Divs rather than `ul`/`li`, because lint rejects interactive roles on
|
|
41
|
+
// list elements.
|
|
42
|
+
<div aria-label={label} className={listStyles} id={id} role="listbox">
|
|
43
|
+
{items.map((item, at) => (
|
|
44
|
+
// biome-ignore lint/a11y/useKeyWithClickEvents: the combobox that names this list owns the keyboard for these rows, which is the whole point of `aria-activedescendant`
|
|
45
|
+
<div
|
|
46
|
+
aria-selected={at === highlighted}
|
|
47
|
+
className={rowStyles}
|
|
48
|
+
data-highlighted={at === highlighted ? "" : undefined}
|
|
49
|
+
id={resultId(id, at)}
|
|
50
|
+
key={keyOf(item)}
|
|
51
|
+
onClick={() => {
|
|
52
|
+
onChoose(item);
|
|
53
|
+
}}
|
|
54
|
+
// Keeps focus in the box.
|
|
55
|
+
onMouseDown={(event) => {
|
|
56
|
+
event.preventDefault();
|
|
57
|
+
}}
|
|
58
|
+
// Pointer move rather than enter, so rows scrolling under a still
|
|
59
|
+
// pointer don't take the highlight.
|
|
60
|
+
onPointerMove={() => {
|
|
61
|
+
onHighlight(at);
|
|
62
|
+
}}
|
|
63
|
+
// A stable function on the highlighted row only, so React calls it
|
|
64
|
+
// only when the highlight moves.
|
|
65
|
+
ref={at === highlighted ? keepInView : undefined}
|
|
66
|
+
role="option"
|
|
67
|
+
// Out of the tab order; focus stays in the box.
|
|
68
|
+
tabIndex={-1}
|
|
69
|
+
>
|
|
70
|
+
{children(item)}
|
|
71
|
+
</div>
|
|
72
|
+
))}
|
|
73
|
+
</div>
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
const listStyles = flex({
|
|
77
|
+
direction: "column",
|
|
78
|
+
gap: 0.5,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const rowStyles = hstack({
|
|
82
|
+
// A neutral wash, so color is left for what needs attention. There is no
|
|
83
|
+
// separate hover style; the pointer moves the highlight.
|
|
84
|
+
"&[data-highlighted]": {
|
|
85
|
+
backgroundColor:
|
|
86
|
+
"color-mix(in oklab, {colors.foreground} 10%, transparent)",
|
|
87
|
+
},
|
|
88
|
+
borderRadius: "md",
|
|
89
|
+
color: "foreground",
|
|
90
|
+
cursor: "pointer",
|
|
91
|
+
fontSize: "sm",
|
|
92
|
+
gap: 2.5,
|
|
93
|
+
paddingBlock: 1.5,
|
|
94
|
+
paddingInline: 2,
|
|
95
|
+
transition: "background-color {durations.fast} {easings.out}",
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
/** The row at `at`'s element ID, for `aria-activedescendant`. */
|
|
99
|
+
export const resultId = (listId: string, at: number): string =>
|
|
100
|
+
`${listId}-${at.toString()}`;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { Icon } from "@phosphor-icons/react";
|
|
2
|
+
|
|
3
|
+
import { css, cva } from "../../styled-system/css";
|
|
4
|
+
|
|
5
|
+
/** The glyph's size in its tile. */
|
|
6
|
+
const ICON_SIZE = 16;
|
|
7
|
+
|
|
8
|
+
type Props = {
|
|
9
|
+
icon: Icon;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A row's glyph in a framed tile, at the start of a {@link ResultList} row.
|
|
14
|
+
* The tile aligns every row's text and lights with the row.
|
|
15
|
+
*
|
|
16
|
+
* Draws the outline and fill weights and shows one, since CSS can't change a
|
|
17
|
+
* Phosphor glyph's weight. Same approach as `_control/PrefixIconStack`.
|
|
18
|
+
*/
|
|
19
|
+
export const RowTile = ({ icon: Glyph }: Props) => (
|
|
20
|
+
<span aria-hidden className={tileStyles} data-row-tile="">
|
|
21
|
+
<span className={glyphStyles({ weight: "regular" })}>
|
|
22
|
+
<Glyph size={ICON_SIZE} />
|
|
23
|
+
</span>
|
|
24
|
+
<span className={glyphStyles({ weight: "fill" })}>
|
|
25
|
+
<Glyph size={ICON_SIZE} weight="fill" />
|
|
26
|
+
</span>
|
|
27
|
+
</span>
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
const tileStyles = css({
|
|
31
|
+
// Lit with a filled glyph on the highlighted row, since an outline glyph
|
|
32
|
+
// loses contrast on the lighter background.
|
|
33
|
+
"[data-highlighted] > &": {
|
|
34
|
+
backgroundColor:
|
|
35
|
+
"color-mix(in oklab, {colors.foreground} 10%, transparent)",
|
|
36
|
+
borderColor: "color-mix(in oklab, {colors.foreground} 20%, transparent)",
|
|
37
|
+
color: "foreground",
|
|
38
|
+
},
|
|
39
|
+
blockSize: 7,
|
|
40
|
+
border: "1px solid color-mix(in oklab, {colors.foreground} 8%, transparent)",
|
|
41
|
+
borderRadius: "sm",
|
|
42
|
+
color: "muted",
|
|
43
|
+
display: "grid",
|
|
44
|
+
flexShrink: 0,
|
|
45
|
+
inlineSize: 7,
|
|
46
|
+
placeItems: "center",
|
|
47
|
+
transition:
|
|
48
|
+
"background-color {durations.fast} {easings.out}, border-color {durations.fast} {easings.out}, color {durations.fast} {easings.out}",
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// Both weights share one grid cell, so swapping doesn't shift layout.
|
|
52
|
+
const glyphStyles = cva({
|
|
53
|
+
base: {
|
|
54
|
+
display: "inline-flex",
|
|
55
|
+
gridArea: "1 / 1",
|
|
56
|
+
transition: "opacity {durations.fast} {easings.out}",
|
|
57
|
+
},
|
|
58
|
+
variants: {
|
|
59
|
+
weight: {
|
|
60
|
+
fill: {
|
|
61
|
+
"[data-highlighted] > [data-row-tile] > &": { opacity: 1 },
|
|
62
|
+
opacity: 0,
|
|
63
|
+
},
|
|
64
|
+
regular: {
|
|
65
|
+
"[data-highlighted] > [data-row-tile] > &": { opacity: 0 },
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Marks the letters of a row that match the query, so users see why it matched.
|
|
2
|
+
// Matches as the launcher's file search does (`domicile_host::file_search`):
|
|
3
|
+
// every query word, ignoring case. Every occurrence of any word is marked.
|
|
4
|
+
|
|
5
|
+
/** A run of a row's text, and whether it matched the query. */
|
|
6
|
+
export type Mark = {
|
|
7
|
+
matched: boolean;
|
|
8
|
+
text: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/** Split `text` into matched and unmatched runs for `query`. */
|
|
12
|
+
export const marked = (text: string, query: string): readonly Mark[] => {
|
|
13
|
+
const lowered = text.toLowerCase();
|
|
14
|
+
const words = query
|
|
15
|
+
.toLowerCase()
|
|
16
|
+
.split(/\s+/)
|
|
17
|
+
.filter((word) => word !== "");
|
|
18
|
+
const runs = merged(
|
|
19
|
+
words
|
|
20
|
+
.flatMap((word) => occurrences(lowered, word))
|
|
21
|
+
.sort((one, other) => one.at - other.at),
|
|
22
|
+
);
|
|
23
|
+
return cut(text, runs);
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** A matched span of a row's text. */
|
|
27
|
+
type Run = { at: number; to: number };
|
|
28
|
+
|
|
29
|
+
const occurrences = (lowered: string, word: string): readonly Run[] => {
|
|
30
|
+
const found: Run[] = [];
|
|
31
|
+
// `indexOf` rather than a regexp, so `a.b` and `c++` match literally
|
|
32
|
+
// without escaping.
|
|
33
|
+
for (
|
|
34
|
+
let at = lowered.indexOf(word);
|
|
35
|
+
at !== -1;
|
|
36
|
+
at = lowered.indexOf(word, at + 1)
|
|
37
|
+
) {
|
|
38
|
+
found.push({ at, to: at + word.length });
|
|
39
|
+
}
|
|
40
|
+
return found;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Merge touching or overlapping runs.
|
|
45
|
+
*
|
|
46
|
+
* Words can overlap (`note notes`), and unmerged runs would show a seam.
|
|
47
|
+
*/
|
|
48
|
+
const merged = (runs: readonly Run[]): readonly Run[] =>
|
|
49
|
+
runs.reduce<Run[]>((folded, run) => {
|
|
50
|
+
const last = folded.at(-1);
|
|
51
|
+
if (last !== undefined && run.at <= last.to) {
|
|
52
|
+
return [
|
|
53
|
+
...folded.slice(0, -1),
|
|
54
|
+
{ at: last.at, to: Math.max(last.to, run.to) },
|
|
55
|
+
];
|
|
56
|
+
} else {
|
|
57
|
+
return [...folded, run];
|
|
58
|
+
}
|
|
59
|
+
}, []);
|
|
60
|
+
|
|
61
|
+
/** Split `text` at the edges of `runs` into marked pieces. */
|
|
62
|
+
const cut = (text: string, runs: readonly Run[]): readonly Mark[] => {
|
|
63
|
+
const pieces = runs.flatMap((run, index) => [
|
|
64
|
+
{ matched: false, text: text.slice(runs[index - 1]?.to ?? 0, run.at) },
|
|
65
|
+
{ matched: true, text: text.slice(run.at, run.to) },
|
|
66
|
+
]);
|
|
67
|
+
const rest = { matched: false, text: text.slice(runs.at(-1)?.to ?? 0) };
|
|
68
|
+
return [...pieces, rest].filter((piece) => piece.text !== "");
|
|
69
|
+
};
|