chore: initialize qiming workspace repository
This commit is contained in:
123
qimingcode/packages/ui/src/components/accordion.css
Normal file
123
qimingcode/packages/ui/src/components/accordion.css
Normal file
@@ -0,0 +1,123 @@
|
||||
[data-component="accordion"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0px;
|
||||
align-self: stretch;
|
||||
|
||||
[data-slot="accordion-item"] {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
align-self: stretch;
|
||||
overflow: visible;
|
||||
|
||||
& + [data-slot="accordion-item"] {
|
||||
margin-top: -1px;
|
||||
}
|
||||
|
||||
[data-slot="accordion-header"] {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
[data-slot="accordion-trigger"] {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
height: 32px;
|
||||
padding: 8px 12px;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
align-self: stretch;
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
|
||||
background-color: var(--background-stronger);
|
||||
border: 1px solid var(--border-weak-base);
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
overflow: clip;
|
||||
color: var(--text-strong);
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
/* text-12-regular */
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-small);
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-regular);
|
||||
line-height: var(--line-height-large); /* 166.667% */
|
||||
letter-spacing: var(--letter-spacing-normal);
|
||||
|
||||
&:hover:not([data-disabled]) {
|
||||
background-color: var(--surface-base-hover);
|
||||
}
|
||||
&:active:not([data-disabled]) {
|
||||
background-color: var(--surface-base-active);
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
&[data-disabled] {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
[data-slot="accordion-header"] [data-slot="accordion-trigger"] {
|
||||
border-top-left-radius: var(--radius-lg);
|
||||
border-top-right-radius: var(--radius-lg);
|
||||
}
|
||||
}
|
||||
|
||||
&:last-child:not([data-expanded]) {
|
||||
[data-slot="accordion-header"] [data-slot="accordion-trigger"] {
|
||||
border-bottom-left-radius: var(--radius-lg);
|
||||
border-bottom-right-radius: var(--radius-lg);
|
||||
}
|
||||
}
|
||||
|
||||
&[data-expanded] {
|
||||
[data-slot="accordion-content"] {
|
||||
border: 1px solid var(--border-weak-base);
|
||||
border-top: 0;
|
||||
background-color: var(--background-stronger);
|
||||
}
|
||||
}
|
||||
|
||||
&:last-child[data-expanded] {
|
||||
[data-slot="accordion-content"] {
|
||||
border-bottom-left-radius: var(--radius-lg);
|
||||
border-bottom-right-radius: var(--radius-lg);
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="accordion-content"] {
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
height: 0;
|
||||
}
|
||||
to {
|
||||
height: var(--kb-accordion-content-height);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
height: var(--kb-accordion-content-height);
|
||||
}
|
||||
to {
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
149
qimingcode/packages/ui/src/components/accordion.stories.tsx
Normal file
149
qimingcode/packages/ui/src/components/accordion.stories.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
// @ts-nocheck
|
||||
import { createEffect, createSignal } from "solid-js"
|
||||
import * as mod from "./accordion"
|
||||
import { create } from "../storybook/scaffold"
|
||||
|
||||
const docs = `### Overview
|
||||
Accordion for collapsible content sections with optional multi-open behavior.
|
||||
|
||||
Use one trigger per item; keep content concise.
|
||||
|
||||
### API
|
||||
- Root supports Kobalte Accordion props: \`value\`, \`multiple\`, \`collapsible\`, \`onChange\`.
|
||||
- Compose with \`Accordion.Item\`, \`Header\`, \`Trigger\`, \`Content\`.
|
||||
|
||||
### Variants and states
|
||||
- Single or multiple open items.
|
||||
- Collapsible or fixed-open behavior.
|
||||
|
||||
### Behavior
|
||||
- Controlled via \`value\`/\`onChange\` when provided.
|
||||
|
||||
### Accessibility
|
||||
- TODO: confirm keyboard navigation from Kobalte Accordion.
|
||||
|
||||
### Theming/tokens
|
||||
- Uses \`data-component="accordion"\` and slot data attributes.
|
||||
|
||||
`
|
||||
|
||||
const story = create({ title: "UI/Accordion", mod })
|
||||
export default {
|
||||
title: "UI/Accordion",
|
||||
id: "components-accordion",
|
||||
component: story.meta.component,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: docs,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
export const Basic = {
|
||||
args: {
|
||||
collapsible: true,
|
||||
multiple: false,
|
||||
value: "first",
|
||||
},
|
||||
argTypes: {
|
||||
collapsible: { control: "boolean" },
|
||||
multiple: { control: "boolean" },
|
||||
value: {
|
||||
control: "select",
|
||||
options: ["first", "second", "none"],
|
||||
mapping: {
|
||||
none: undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
render: (props) => {
|
||||
const [value, setValue] = createSignal(props.value)
|
||||
createEffect(() => {
|
||||
setValue(props.value)
|
||||
})
|
||||
|
||||
const current = () => {
|
||||
if (props.multiple) {
|
||||
if (Array.isArray(value())) return value()
|
||||
if (value()) return [value()]
|
||||
return []
|
||||
}
|
||||
|
||||
if (Array.isArray(value())) return value()[0]
|
||||
return value()
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: "grid", gap: "8px", width: "420px" }}>
|
||||
<mod.Accordion collapsible={props.collapsible} multiple={props.multiple} value={current()} onChange={setValue}>
|
||||
<mod.Accordion.Item value="first">
|
||||
<mod.Accordion.Header>
|
||||
<mod.Accordion.Trigger>First</mod.Accordion.Trigger>
|
||||
</mod.Accordion.Header>
|
||||
<mod.Accordion.Content>
|
||||
<div style={{ color: "var(--text-weak)", padding: "8px 0" }}>Accordion content.</div>
|
||||
</mod.Accordion.Content>
|
||||
</mod.Accordion.Item>
|
||||
<mod.Accordion.Item value="second">
|
||||
<mod.Accordion.Header>
|
||||
<mod.Accordion.Trigger>Second</mod.Accordion.Trigger>
|
||||
</mod.Accordion.Header>
|
||||
<mod.Accordion.Content>
|
||||
<div style={{ color: "var(--text-weak)", padding: "8px 0" }}>More content.</div>
|
||||
</mod.Accordion.Content>
|
||||
</mod.Accordion.Item>
|
||||
</mod.Accordion>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
export const Multiple = {
|
||||
args: {
|
||||
collapsible: true,
|
||||
multiple: true,
|
||||
value: ["first", "second"],
|
||||
},
|
||||
render: (props) => (
|
||||
<mod.Accordion collapsible={props.collapsible} multiple={props.multiple} value={props.value}>
|
||||
<mod.Accordion.Item value="first">
|
||||
<mod.Accordion.Header>
|
||||
<mod.Accordion.Trigger>First</mod.Accordion.Trigger>
|
||||
</mod.Accordion.Header>
|
||||
<mod.Accordion.Content>
|
||||
<div style={{ color: "var(--text-weak)", padding: "8px 0" }}>Accordion content.</div>
|
||||
</mod.Accordion.Content>
|
||||
</mod.Accordion.Item>
|
||||
<mod.Accordion.Item value="second">
|
||||
<mod.Accordion.Header>
|
||||
<mod.Accordion.Trigger>Second</mod.Accordion.Trigger>
|
||||
</mod.Accordion.Header>
|
||||
<mod.Accordion.Content>
|
||||
<div style={{ color: "var(--text-weak)", padding: "8px 0" }}>More content.</div>
|
||||
</mod.Accordion.Content>
|
||||
</mod.Accordion.Item>
|
||||
</mod.Accordion>
|
||||
),
|
||||
}
|
||||
|
||||
export const NonCollapsible = {
|
||||
args: {
|
||||
collapsible: false,
|
||||
multiple: false,
|
||||
value: "first",
|
||||
},
|
||||
render: (props) => (
|
||||
<mod.Accordion collapsible={props.collapsible} multiple={props.multiple} value={props.value}>
|
||||
<mod.Accordion.Item value="first">
|
||||
<mod.Accordion.Header>
|
||||
<mod.Accordion.Trigger>First</mod.Accordion.Trigger>
|
||||
</mod.Accordion.Header>
|
||||
<mod.Accordion.Content>
|
||||
<div style={{ color: "var(--text-weak)", padding: "8px 0" }}>Accordion content.</div>
|
||||
</mod.Accordion.Content>
|
||||
</mod.Accordion.Item>
|
||||
</mod.Accordion>
|
||||
),
|
||||
}
|
||||
92
qimingcode/packages/ui/src/components/accordion.tsx
Normal file
92
qimingcode/packages/ui/src/components/accordion.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { Accordion as Kobalte } from "@kobalte/core/accordion"
|
||||
import { splitProps } from "solid-js"
|
||||
import type { ComponentProps, ParentProps } from "solid-js"
|
||||
|
||||
export interface AccordionProps extends ComponentProps<typeof Kobalte> {}
|
||||
export interface AccordionItemProps extends ComponentProps<typeof Kobalte.Item> {}
|
||||
export interface AccordionHeaderProps extends ComponentProps<typeof Kobalte.Header> {}
|
||||
export interface AccordionTriggerProps extends ComponentProps<typeof Kobalte.Trigger> {}
|
||||
export interface AccordionContentProps extends ComponentProps<typeof Kobalte.Content> {}
|
||||
|
||||
function AccordionRoot(props: AccordionProps) {
|
||||
const [split, rest] = splitProps(props, ["class", "classList"])
|
||||
return (
|
||||
<Kobalte
|
||||
{...rest}
|
||||
data-component="accordion"
|
||||
classList={{
|
||||
...split.classList,
|
||||
[split.class ?? ""]: !!split.class,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AccordionItem(props: AccordionItemProps) {
|
||||
const [split, rest] = splitProps(props, ["class", "classList"])
|
||||
return (
|
||||
<Kobalte.Item
|
||||
{...rest}
|
||||
data-slot="accordion-item"
|
||||
classList={{
|
||||
...split.classList,
|
||||
[split.class ?? ""]: !!split.class,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AccordionHeader(props: ParentProps<AccordionHeaderProps>) {
|
||||
const [split, rest] = splitProps(props, ["class", "classList", "children"])
|
||||
return (
|
||||
<Kobalte.Header
|
||||
{...rest}
|
||||
data-slot="accordion-header"
|
||||
classList={{
|
||||
...split.classList,
|
||||
[split.class ?? ""]: !!split.class,
|
||||
}}
|
||||
>
|
||||
{split.children}
|
||||
</Kobalte.Header>
|
||||
)
|
||||
}
|
||||
|
||||
function AccordionTrigger(props: ParentProps<AccordionTriggerProps>) {
|
||||
const [split, rest] = splitProps(props, ["class", "classList", "children"])
|
||||
return (
|
||||
<Kobalte.Trigger
|
||||
{...rest}
|
||||
data-slot="accordion-trigger"
|
||||
classList={{
|
||||
...split.classList,
|
||||
[split.class ?? ""]: !!split.class,
|
||||
}}
|
||||
>
|
||||
{split.children}
|
||||
</Kobalte.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function AccordionContent(props: ParentProps<AccordionContentProps>) {
|
||||
const [split, rest] = splitProps(props, ["class", "classList", "children"])
|
||||
return (
|
||||
<Kobalte.Content
|
||||
{...rest}
|
||||
data-slot="accordion-content"
|
||||
classList={{
|
||||
...split.classList,
|
||||
[split.class ?? ""]: !!split.class,
|
||||
}}
|
||||
>
|
||||
{split.children}
|
||||
</Kobalte.Content>
|
||||
)
|
||||
}
|
||||
|
||||
export const Accordion = Object.assign(AccordionRoot, {
|
||||
Item: AccordionItem,
|
||||
Header: AccordionHeader,
|
||||
Trigger: AccordionTrigger,
|
||||
Content: AccordionContent,
|
||||
})
|
||||
75
qimingcode/packages/ui/src/components/animated-number.css
Normal file
75
qimingcode/packages/ui/src/components/animated-number.css
Normal file
@@ -0,0 +1,75 @@
|
||||
[data-component="animated-number"] {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
vertical-align: baseline;
|
||||
line-height: inherit;
|
||||
font-variant-numeric: tabular-nums;
|
||||
|
||||
[data-slot="animated-number-value"] {
|
||||
display: inline-flex;
|
||||
flex-direction: row-reverse;
|
||||
align-items: baseline;
|
||||
justify-content: flex-end;
|
||||
line-height: inherit;
|
||||
width: var(--animated-number-width, 1ch);
|
||||
overflow: hidden;
|
||||
transition: width var(--tool-motion-spring-ms, 560ms) var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1));
|
||||
}
|
||||
|
||||
[data-slot="animated-number-digit"] {
|
||||
display: inline-block;
|
||||
width: 1ch;
|
||||
height: 1em;
|
||||
line-height: 1em;
|
||||
overflow: hidden;
|
||||
vertical-align: baseline;
|
||||
-webkit-mask-image: linear-gradient(
|
||||
to bottom,
|
||||
transparent 0%,
|
||||
#000 var(--tool-motion-mask, 18%),
|
||||
#000 calc(100% - var(--tool-motion-mask, 18%)),
|
||||
transparent 100%
|
||||
);
|
||||
mask-image: linear-gradient(
|
||||
to bottom,
|
||||
transparent 0%,
|
||||
#000 var(--tool-motion-mask, 18%),
|
||||
#000 calc(100% - var(--tool-motion-mask, 18%)),
|
||||
transparent 100%
|
||||
);
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
mask-repeat: no-repeat;
|
||||
}
|
||||
|
||||
[data-slot="animated-number-strip"] {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
transform: translateY(calc(var(--animated-number-offset, 10) * -1em));
|
||||
transition-property: transform;
|
||||
transition-duration: var(--animated-number-duration, 560ms);
|
||||
transition-timing-function: var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1));
|
||||
}
|
||||
|
||||
[data-slot="animated-number-strip"][data-animating="false"] {
|
||||
transition-duration: 0ms;
|
||||
}
|
||||
|
||||
[data-slot="animated-number-cell"] {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1ch;
|
||||
height: 1em;
|
||||
line-height: 1em;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
[data-component="animated-number"] [data-slot="animated-number-value"] {
|
||||
transition-duration: 0ms;
|
||||
}
|
||||
|
||||
[data-component="animated-number"] [data-slot="animated-number-strip"] {
|
||||
transition-duration: 0ms;
|
||||
}
|
||||
}
|
||||
109
qimingcode/packages/ui/src/components/animated-number.tsx
Normal file
109
qimingcode/packages/ui/src/components/animated-number.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { For, Index, createEffect, createMemo, on } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
|
||||
const TRACK = Array.from({ length: 30 }, (_, index) => index % 10)
|
||||
const DURATION = 600
|
||||
|
||||
function normalize(value: number) {
|
||||
return ((value % 10) + 10) % 10
|
||||
}
|
||||
|
||||
function spin(from: number, to: number, direction: 1 | -1) {
|
||||
if (from === to) return 0
|
||||
if (direction > 0) return (to - from + 10) % 10
|
||||
return -((from - to + 10) % 10)
|
||||
}
|
||||
|
||||
function Digit(props: { value: number; direction: 1 | -1 }) {
|
||||
const [state, setState] = createStore({
|
||||
step: props.value + 10,
|
||||
animating: false,
|
||||
})
|
||||
const step = () => state.step
|
||||
const animating = () => state.animating
|
||||
let last = props.value
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => props.value,
|
||||
(next) => {
|
||||
const delta = spin(last, next, props.direction)
|
||||
last = next
|
||||
if (!delta) {
|
||||
setState("animating", false)
|
||||
setState("step", next + 10)
|
||||
return
|
||||
}
|
||||
|
||||
setState("animating", true)
|
||||
setState("step", (value) => value + delta)
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
return (
|
||||
<span data-slot="animated-number-digit">
|
||||
<span
|
||||
data-slot="animated-number-strip"
|
||||
data-animating={animating() ? "true" : "false"}
|
||||
onTransitionEnd={() => {
|
||||
setState("animating", false)
|
||||
setState("step", (value) => normalize(value) + 10)
|
||||
}}
|
||||
style={{
|
||||
"--animated-number-offset": `${step()}`,
|
||||
"--animated-number-duration": `var(--tool-motion-odometer-ms, ${DURATION}ms)`,
|
||||
}}
|
||||
>
|
||||
<For each={TRACK}>{(value) => <span data-slot="animated-number-cell">{value}</span>}</For>
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function AnimatedNumber(props: { value: number; class?: string }) {
|
||||
const target = createMemo(() => {
|
||||
if (!Number.isFinite(props.value)) return 0
|
||||
return Math.max(0, Math.round(props.value))
|
||||
})
|
||||
|
||||
const [state, setState] = createStore({
|
||||
value: target(),
|
||||
direction: 1 as 1 | -1,
|
||||
})
|
||||
const value = () => state.value
|
||||
const direction = () => state.direction
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
target,
|
||||
(next) => {
|
||||
const current = value()
|
||||
if (next === current) return
|
||||
|
||||
setState("direction", next > current ? 1 : -1)
|
||||
setState("value", next)
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
const label = createMemo(() => value().toString())
|
||||
const digits = createMemo(() =>
|
||||
Array.from(label(), (char) => {
|
||||
const code = char.charCodeAt(0) - 48
|
||||
if (code < 0 || code > 9) return 0
|
||||
return code
|
||||
}).reverse(),
|
||||
)
|
||||
const width = createMemo(() => `${digits().length}ch`)
|
||||
|
||||
return (
|
||||
<span data-component="animated-number" class={props.class} aria-label={label()}>
|
||||
<span data-slot="animated-number-value" style={{ "--animated-number-width": width() }}>
|
||||
<Index each={digits()}>{(digit) => <Digit value={digit()} direction={direction()} />}</Index>
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
5
qimingcode/packages/ui/src/components/app-icon.css
Normal file
5
qimingcode/packages/ui/src/components/app-icon.css
Normal file
@@ -0,0 +1,5 @@
|
||||
img[data-component="app-icon"] {
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
object-fit: contain;
|
||||
}
|
||||
69
qimingcode/packages/ui/src/components/app-icon.stories.tsx
Normal file
69
qimingcode/packages/ui/src/components/app-icon.stories.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
// @ts-nocheck
|
||||
import { iconNames } from "./app-icons/types"
|
||||
import * as mod from "./app-icon"
|
||||
import { create } from "../storybook/scaffold"
|
||||
|
||||
const docs = `### Overview
|
||||
Application icon renderer for known editor/terminal apps.
|
||||
|
||||
Use in provider or app selection lists.
|
||||
|
||||
### API
|
||||
- Required: \`id\` (app icon name).
|
||||
- Accepts standard img props except \`src\`.
|
||||
|
||||
### Variants and states
|
||||
- Auto-switches themed icons when available.
|
||||
|
||||
### Behavior
|
||||
- Watches color scheme changes to swap themed assets.
|
||||
|
||||
### Accessibility
|
||||
- Provide \`alt\` text when the icon conveys meaning.
|
||||
|
||||
### Theming/tokens
|
||||
- Uses \`data-component="app-icon"\`.
|
||||
|
||||
`
|
||||
|
||||
const story = create({ title: "UI/AppIcon", mod, args: { id: "vscode" } })
|
||||
export default {
|
||||
title: "UI/AppIcon",
|
||||
id: "components-app-icon",
|
||||
component: story.meta.component,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: docs,
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
id: {
|
||||
control: "select",
|
||||
options: iconNames,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const Basic = story.Basic
|
||||
|
||||
export const AllIcons = {
|
||||
render: () => (
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gap: "12px",
|
||||
"grid-template-columns": "repeat(auto-fill, minmax(72px, 1fr))",
|
||||
}}
|
||||
>
|
||||
{iconNames.map((id) => (
|
||||
<div style={{ display: "grid", gap: "6px", "justify-items": "center" }}>
|
||||
<mod.AppIcon id={id} alt={id} />
|
||||
<div style={{ "font-size": "10px", color: "var(--text-weak)", "text-align": "center" }}>{id}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}
|
||||
85
qimingcode/packages/ui/src/components/app-icon.tsx
Normal file
85
qimingcode/packages/ui/src/components/app-icon.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import type { Component, ComponentProps } from "solid-js"
|
||||
import { createSignal, onCleanup, onMount, splitProps } from "solid-js"
|
||||
import type { IconName } from "./app-icons/types"
|
||||
|
||||
import androidStudio from "../assets/icons/app/android-studio.svg"
|
||||
import antigravity from "../assets/icons/app/antigravity.svg"
|
||||
import cursor from "../assets/icons/app/cursor.svg"
|
||||
import fileExplorer from "../assets/icons/app/file-explorer.svg"
|
||||
import finder from "../assets/icons/app/finder.png"
|
||||
import ghostty from "../assets/icons/app/ghostty.svg"
|
||||
import iterm2 from "../assets/icons/app/iterm2.svg"
|
||||
import powershell from "../assets/icons/app/powershell.svg"
|
||||
import terminal from "../assets/icons/app/terminal.png"
|
||||
import textmate from "../assets/icons/app/textmate.png"
|
||||
import vscode from "../assets/icons/app/vscode.svg"
|
||||
import warp from "../assets/icons/app/warp.png"
|
||||
import xcode from "../assets/icons/app/xcode.png"
|
||||
import zed from "../assets/icons/app/zed.svg"
|
||||
import zedDark from "../assets/icons/app/zed-dark.svg"
|
||||
import sublimetext from "../assets/icons/app/sublimetext.svg"
|
||||
|
||||
const icons = {
|
||||
vscode,
|
||||
cursor,
|
||||
zed,
|
||||
"file-explorer": fileExplorer,
|
||||
finder,
|
||||
terminal,
|
||||
iterm2,
|
||||
ghostty,
|
||||
warp,
|
||||
xcode,
|
||||
"android-studio": androidStudio,
|
||||
antigravity,
|
||||
textmate,
|
||||
powershell,
|
||||
"sublime-text": sublimetext,
|
||||
} satisfies Record<IconName, string>
|
||||
|
||||
const themed: Partial<Record<IconName, { light: string; dark: string }>> = {
|
||||
zed: {
|
||||
light: zed,
|
||||
dark: zedDark,
|
||||
},
|
||||
}
|
||||
|
||||
const scheme = () => {
|
||||
if (typeof document !== "object") return "light" as const
|
||||
if (document.documentElement.dataset.colorScheme === "dark") return "dark" as const
|
||||
return "light" as const
|
||||
}
|
||||
|
||||
export type AppIconProps = Omit<ComponentProps<"img">, "src"> & {
|
||||
id: IconName
|
||||
}
|
||||
|
||||
export const AppIcon: Component<AppIconProps> = (props) => {
|
||||
const [local, rest] = splitProps(props, ["id", "class", "classList", "alt", "draggable"])
|
||||
const [mode, setMode] = createSignal(scheme())
|
||||
|
||||
onMount(() => {
|
||||
const sync = () => setMode(scheme())
|
||||
const observer = new MutationObserver(sync)
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["data-color-scheme"],
|
||||
})
|
||||
sync()
|
||||
onCleanup(() => observer.disconnect())
|
||||
})
|
||||
|
||||
return (
|
||||
<img
|
||||
data-component="app-icon"
|
||||
{...rest}
|
||||
src={themed[local.id]?.[mode()] ?? icons[local.id]}
|
||||
alt={local.alt ?? ""}
|
||||
draggable={local.draggable ?? false}
|
||||
classList={{
|
||||
...local.classList,
|
||||
[local.class ?? ""]: !!local.class,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
114
qimingcode/packages/ui/src/components/app-icons/sprite.svg
Normal file
114
qimingcode/packages/ui/src/components/app-icons/sprite.svg
Normal file
@@ -0,0 +1,114 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="0" height="0">
|
||||
<defs>
|
||||
<symbol id="vscode" viewBox="0 0 24 24">
|
||||
<rect width="24" height="24" rx="5" fill="#007ACC" />
|
||||
<g transform="scale(1.5)">
|
||||
<path
|
||||
fill="#fff"
|
||||
d="M11.5 11.19V4.8L7.3 7.99M1.17 6.07a.6.6 0 0 1-.01-.81L2 4.48c.14-.13.48-.18.73 0l2.39 1.83 5.55-5.09c.22-.22.61-.32 1.05-.08l2.8 1.34c.25.15.49.38.49.81v9.49c0 .28-.2.58-.42.7l-3.08 1.48c-.22.09-.64 0-.79-.14L5.11 9.69l-2.38 1.83c-.27.18-.6.13-.74 0l-.84-.77c-.22-.23-.2-.61.04-.84l2.1-1.9"
|
||||
/>
|
||||
</g>
|
||||
</symbol>
|
||||
|
||||
<symbol id="cursor" viewBox="0 0 24 24">
|
||||
<rect width="24" height="24" rx="5" fill="#111827" />
|
||||
<path
|
||||
fill="#fff"
|
||||
d="M11.503.131 1.891 5.678a.84.84 0 0 0-.42.726v11.188c0 .3.162.575.42.724l9.609 5.55a1 1 0 0 0 .998 0l9.61-5.55a.84.84 0 0 0 .42-.724V6.404a.84.84 0 0 0-.42-.726L12.497.131a1.01 1.01 0 0 0-.996 0M2.657 6.338h18.55c.263 0 .43.287.297.515L12.23 22.918c-.062.107-.229.064-.229-.06V12.335a.59.59 0 0 0-.295-.51l-9.11-5.257c-.109-.063-.064-.23.061-.23"
|
||||
/>
|
||||
</symbol>
|
||||
|
||||
<symbol id="zed" viewBox="0 0 24 24">
|
||||
<rect width="24" height="24" rx="5" fill="#084CCF" />
|
||||
<g transform="translate(12 12) scale(0.9) translate(-12 -12)">
|
||||
<path
|
||||
fill="#fff"
|
||||
d="M2.25 1.5a.75.75 0 0 0-.75.75v16.5H0V2.25A2.25 2.25 0 0 1 2.25 0h20.095c1.002 0 1.504 1.212.795 1.92L10.764 14.298h3.486V12.75h1.5v1.922a1.125 1.125 0 0 1-1.125 1.125H9.264l-2.578 2.578h11.689V9h1.5v9.375a1.5 1.5 0 0 1-1.5 1.5H5.185L2.562 22.5H21.75a.75.75 0 0 0 .75-.75V5.25H24v16.5A2.25 2.25 0 0 1 21.75 24H1.655C.653 24 .151 22.788.86 22.08L13.19 9.75H9.75v1.5h-1.5V9.375A1.125 1.125 0 0 1 9.375 8.25h5.314l2.625-2.625H5.625V15h-1.5V5.625a1.5 1.5 0 0 1 1.5-1.5h13.19L21.438 1.5z"
|
||||
/>
|
||||
</g>
|
||||
</symbol>
|
||||
|
||||
<symbol id="finder" viewBox="0 0 24 24">
|
||||
<rect width="24" height="24" rx="5" fill="#8ED0FF" />
|
||||
<path d="M12 0H19a5 5 0 0 1 5 5V19a5 5 0 0 1-5 5H12Z" fill="#2D7BF7" />
|
||||
<path d="M12 3v18" stroke="#0B2A4A" stroke-opacity="0.35" stroke-width="1.5" />
|
||||
<circle cx="8.3" cy="9.2" r="1.1" fill="#0B2A4A" />
|
||||
<circle cx="15.7" cy="9.2" r="1.1" fill="#0B2A4A" />
|
||||
<path
|
||||
d="M7.3 15c1.2 1.55 2.9 2.4 4.7 2.4s3.5-.85 4.7-2.4"
|
||||
stroke="#0B2A4A"
|
||||
stroke-width="1.5"
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</symbol>
|
||||
|
||||
<symbol id="terminal" viewBox="0 0 24 24">
|
||||
<rect width="24" height="24" rx="5" fill="#111827" />
|
||||
<rect
|
||||
x="3.5"
|
||||
y="4.5"
|
||||
width="17"
|
||||
height="15"
|
||||
rx="2.5"
|
||||
fill="#0B1220"
|
||||
stroke="#334155"
|
||||
stroke-opacity="0.5"
|
||||
/>
|
||||
<path
|
||||
d="M7.8 9.2 11 12 7.8 14.8"
|
||||
stroke="#34D399"
|
||||
stroke-width="1.8"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<rect x="12.2" y="14.2" width="5.4" height="1.6" rx="0.8" fill="#34D399" />
|
||||
</symbol>
|
||||
|
||||
<symbol id="iterm2" viewBox="0 0 24 24">
|
||||
<rect width="24" height="24" rx="5" fill="#0B0B0B" />
|
||||
<rect x="3.2" y="4.2" width="17.6" height="15.6" rx="2.4" fill="#000" stroke="#60A5FA" stroke-width="1.2" />
|
||||
<circle cx="5.5" cy="6.3" r="0.75" fill="#F87171" />
|
||||
<circle cx="7.6" cy="6.3" r="0.75" fill="#FBBF24" />
|
||||
<circle cx="9.7" cy="6.3" r="0.75" fill="#34D399" />
|
||||
<path
|
||||
d="M7.9 10.2 10.6 12 7.9 13.8"
|
||||
stroke="#34D399"
|
||||
stroke-width="1.6"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<rect x="11.6" y="13.3" width="5" height="1.4" rx="0.7" fill="#34D399" />
|
||||
</symbol>
|
||||
|
||||
<symbol id="ghostty" viewBox="0 0 24 24">
|
||||
<rect width="24" height="24" rx="5" fill="#3551F3" />
|
||||
<g transform="translate(12 12) scale(0.9) translate(-12 -12)">
|
||||
<path
|
||||
fill="#fff"
|
||||
d="M12 0C6.7 0 2.4 4.3 2.4 9.6v11.146c0 1.772 1.45 3.267 3.222 3.254a3.18 3.18 0 0 0 1.955-.686 1.96 1.96 0 0 1 2.444 0 3.18 3.18 0 0 0 1.976.686c.75 0 1.436-.257 1.98-.686.715-.563 1.71-.587 2.419-.018.59.476 1.355.743 2.182.699 1.705-.094 3.022-1.537 3.022-3.244V9.601C21.6 4.3 17.302 0 12 0M6.069 6.562a1 1 0 0 1 .46.131l3.578 2.065v.002a.974.974 0 0 1 0 1.687L6.53 12.512a.975.975 0 0 1-.976-1.687L7.67 9.602 5.553 8.38a.975.975 0 0 1 .515-1.818m7.438 2.063h4.7a.975.975 0 1 1 0 1.95h-4.7a.975.975 0 0 1 0-1.95"
|
||||
/>
|
||||
</g>
|
||||
</symbol>
|
||||
|
||||
<symbol id="xcode" viewBox="0 0 24 24">
|
||||
<rect width="24" height="24" rx="5" fill="#147EFB" />
|
||||
<path d="M6 8H18" stroke="#fff" stroke-opacity="0.18" stroke-width="1.2" stroke-linecap="round" />
|
||||
<path d="M8 6V18" stroke="#fff" stroke-opacity="0.18" stroke-width="1.2" stroke-linecap="round" />
|
||||
<path d="M6 18H18" stroke="#fff" stroke-opacity="0.18" stroke-width="1.2" stroke-linecap="round" />
|
||||
<path d="M18 6V18" stroke="#fff" stroke-opacity="0.18" stroke-width="1.2" stroke-linecap="round" />
|
||||
<g transform="translate(12 12) rotate(-35) translate(-12 -12)">
|
||||
<rect x="11.1" y="6.2" width="2" height="12.6" rx="1" fill="#fff" />
|
||||
<rect x="9.2" y="5.3" width="5.6" height="2.7" rx="1" fill="#fff" />
|
||||
</g>
|
||||
</symbol>
|
||||
|
||||
<symbol id="android-studio" viewBox="0 0 24 24">
|
||||
<rect width="24" height="24" rx="5" fill="#3DDC84" />
|
||||
<circle cx="12" cy="12.2" r="6.8" fill="#3B82F6" />
|
||||
<circle cx="12" cy="12.2" r="4.8" fill="none" stroke="#fff" stroke-width="1.6" />
|
||||
<path d="M12 9.4l2.2 5-2.2-1.3-2.2 1.3z" fill="#fff" />
|
||||
<circle cx="12" cy="12.2" r="0.9" fill="#fff" />
|
||||
</symbol>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.5 KiB |
21
qimingcode/packages/ui/src/components/app-icons/types.ts
Normal file
21
qimingcode/packages/ui/src/components/app-icons/types.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
// This file is generated by icon spritesheet generator
|
||||
|
||||
export const iconNames = [
|
||||
"vscode",
|
||||
"cursor",
|
||||
"zed",
|
||||
"file-explorer",
|
||||
"finder",
|
||||
"terminal",
|
||||
"iterm2",
|
||||
"ghostty",
|
||||
"warp",
|
||||
"xcode",
|
||||
"android-studio",
|
||||
"antigravity",
|
||||
"textmate",
|
||||
"powershell",
|
||||
"sublime-text",
|
||||
] as const
|
||||
|
||||
export type IconName = (typeof iconNames)[number]
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { patchFiles } from "./apply-patch-file"
|
||||
import { text } from "./session-diff"
|
||||
|
||||
describe("apply patch file", () => {
|
||||
test("parses patch metadata from the server", () => {
|
||||
const file = patchFiles([
|
||||
{
|
||||
filePath: "/tmp/a.ts",
|
||||
relativePath: "a.ts",
|
||||
type: "update",
|
||||
patch:
|
||||
"Index: a.ts\n===================================================================\n--- a.ts\t\n+++ a.ts\t\n@@ -1,2 +1,2 @@\n one\n-two\n+three\n",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
},
|
||||
])[0]
|
||||
|
||||
expect(file).toBeDefined()
|
||||
expect(file?.view.fileDiff.name).toBe("a.ts")
|
||||
expect(text(file!.view, "deletions")).toBe("one\ntwo\n")
|
||||
expect(text(file!.view, "additions")).toBe("one\nthree\n")
|
||||
})
|
||||
|
||||
test("keeps legacy before and after payloads working", () => {
|
||||
const file = patchFiles([
|
||||
{
|
||||
filePath: "/tmp/a.ts",
|
||||
relativePath: "a.ts",
|
||||
type: "update",
|
||||
before: "one\n",
|
||||
after: "two\n",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
},
|
||||
])[0]
|
||||
|
||||
expect(file).toBeDefined()
|
||||
expect(file?.view.patch).toContain("@@ -1,1 +1,1 @@")
|
||||
expect(text(file!.view, "deletions")).toBe("one\n")
|
||||
expect(text(file!.view, "additions")).toBe("two\n")
|
||||
})
|
||||
})
|
||||
78
qimingcode/packages/ui/src/components/apply-patch-file.ts
Normal file
78
qimingcode/packages/ui/src/components/apply-patch-file.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { normalize, type ViewDiff } from "./session-diff"
|
||||
|
||||
type Kind = "add" | "update" | "delete" | "move"
|
||||
|
||||
type Raw = {
|
||||
filePath?: string
|
||||
relativePath?: string
|
||||
type?: Kind
|
||||
patch?: string
|
||||
diff?: string
|
||||
before?: string
|
||||
after?: string
|
||||
additions?: number
|
||||
deletions?: number
|
||||
movePath?: string
|
||||
}
|
||||
|
||||
export type ApplyPatchFile = {
|
||||
filePath: string
|
||||
relativePath: string
|
||||
type: Kind
|
||||
additions: number
|
||||
deletions: number
|
||||
movePath?: string
|
||||
view: ViewDiff
|
||||
}
|
||||
|
||||
function kind(value: unknown) {
|
||||
if (value === "add" || value === "update" || value === "delete" || value === "move") return value
|
||||
}
|
||||
|
||||
function status(type: Kind): "added" | "deleted" | "modified" {
|
||||
if (type === "add") return "added"
|
||||
if (type === "delete") return "deleted"
|
||||
return "modified"
|
||||
}
|
||||
|
||||
export function patchFile(raw: unknown): ApplyPatchFile | undefined {
|
||||
if (!raw || typeof raw !== "object") return
|
||||
|
||||
const value = raw as Raw
|
||||
const type = kind(value.type)
|
||||
const filePath = typeof value.filePath === "string" ? value.filePath : undefined
|
||||
const relativePath = typeof value.relativePath === "string" ? value.relativePath : filePath
|
||||
const patch = typeof value.patch === "string" ? value.patch : typeof value.diff === "string" ? value.diff : undefined
|
||||
const before = typeof value.before === "string" ? value.before : undefined
|
||||
const after = typeof value.after === "string" ? value.after : undefined
|
||||
|
||||
if (!type || !filePath || !relativePath) return
|
||||
if (!patch && before === undefined && after === undefined) return
|
||||
|
||||
const additions = typeof value.additions === "number" ? value.additions : 0
|
||||
const deletions = typeof value.deletions === "number" ? value.deletions : 0
|
||||
const movePath = typeof value.movePath === "string" ? value.movePath : undefined
|
||||
|
||||
return {
|
||||
filePath,
|
||||
relativePath,
|
||||
type,
|
||||
additions,
|
||||
deletions,
|
||||
movePath,
|
||||
view: normalize({
|
||||
file: relativePath,
|
||||
patch,
|
||||
before,
|
||||
after,
|
||||
additions,
|
||||
deletions,
|
||||
status: status(type),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
export function patchFiles(raw: unknown) {
|
||||
if (!Array.isArray(raw)) return []
|
||||
return raw.map(patchFile).filter((file): file is ApplyPatchFile => !!file)
|
||||
}
|
||||
49
qimingcode/packages/ui/src/components/avatar.css
Normal file
49
qimingcode/packages/ui/src/components/avatar.css
Normal file
@@ -0,0 +1,49 @@
|
||||
[data-component="avatar"] {
|
||||
--avatar-bg: var(--color-surface-info-base);
|
||||
--avatar-fg: var(--color-text-base);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--color-border-weak-base);
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
background-color: var(--avatar-bg);
|
||||
color: var(--avatar-fg);
|
||||
}
|
||||
|
||||
[data-component="avatar"][data-has-image] {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
[data-component="avatar"][data-size="small"] {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
[data-component="avatar"][data-size="normal"] {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
font-size: 1.125rem;
|
||||
line-height: 1.5rem;
|
||||
}
|
||||
|
||||
[data-component="avatar"][data-size="large"] {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
font-size: 1.25rem;
|
||||
line-height: 2rem;
|
||||
}
|
||||
|
||||
[data-component="avatar"] [data-slot="avatar-image"] {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
border-radius: inherit;
|
||||
}
|
||||
76
qimingcode/packages/ui/src/components/avatar.stories.tsx
Normal file
76
qimingcode/packages/ui/src/components/avatar.stories.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
// @ts-nocheck
|
||||
import * as mod from "./avatar"
|
||||
import { create } from "../storybook/scaffold"
|
||||
|
||||
const docs = `### Overview
|
||||
User avatar with image fallback to initials.
|
||||
|
||||
Use in user lists and headers.
|
||||
|
||||
### API
|
||||
- Required: \`fallback\` string.
|
||||
- Optional: \`src\`, \`background\`, \`foreground\`, \`size\`.
|
||||
|
||||
### Variants and states
|
||||
- Sizes: small, normal, large.
|
||||
- Image vs fallback state.
|
||||
|
||||
### Behavior
|
||||
- Uses grapheme-aware fallback rendering.
|
||||
|
||||
### Accessibility
|
||||
- TODO: provide alt text when using images; currently image is decorative.
|
||||
|
||||
### Theming/tokens
|
||||
- Uses \`data-component="avatar"\` with size and image state attributes.
|
||||
|
||||
`
|
||||
|
||||
const story = create({ title: "UI/Avatar", mod, args: { fallback: "A" } })
|
||||
|
||||
export default {
|
||||
title: "UI/Avatar",
|
||||
id: "components-avatar",
|
||||
component: story.meta.component,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: docs,
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
size: {
|
||||
control: "select",
|
||||
options: ["small", "normal", "large"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const Basic = story.Basic
|
||||
|
||||
export const WithImage = {
|
||||
args: {
|
||||
src: "https://placehold.co/80x80/png",
|
||||
fallback: "J",
|
||||
},
|
||||
}
|
||||
|
||||
export const Sizes = {
|
||||
render: () => (
|
||||
<div style={{ display: "flex", gap: "12px", "align-items": "center" }}>
|
||||
<mod.Avatar size="small" fallback="S" />
|
||||
<mod.Avatar size="normal" fallback="N" />
|
||||
<mod.Avatar size="large" fallback="L" />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const CustomColors = {
|
||||
args: {
|
||||
fallback: "C",
|
||||
background: "#1f2a44",
|
||||
foreground: "#f2f5ff",
|
||||
},
|
||||
}
|
||||
55
qimingcode/packages/ui/src/components/avatar.tsx
Normal file
55
qimingcode/packages/ui/src/components/avatar.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { type ComponentProps, splitProps, Show } from "solid-js"
|
||||
|
||||
const segmenter =
|
||||
typeof Intl !== "undefined" && "Segmenter" in Intl
|
||||
? new Intl.Segmenter(undefined, { granularity: "grapheme" })
|
||||
: undefined
|
||||
|
||||
function first(value: string) {
|
||||
if (!value) return ""
|
||||
if (!segmenter) return Array.from(value)[0] ?? ""
|
||||
return segmenter.segment(value)[Symbol.iterator]().next().value?.segment ?? Array.from(value)[0] ?? ""
|
||||
}
|
||||
|
||||
export interface AvatarProps extends ComponentProps<"div"> {
|
||||
fallback: string
|
||||
src?: string
|
||||
background?: string
|
||||
foreground?: string
|
||||
size?: "small" | "normal" | "large"
|
||||
}
|
||||
|
||||
export function Avatar(props: AvatarProps) {
|
||||
const [split, rest] = splitProps(props, [
|
||||
"fallback",
|
||||
"src",
|
||||
"background",
|
||||
"foreground",
|
||||
"size",
|
||||
"class",
|
||||
"classList",
|
||||
"style",
|
||||
])
|
||||
const src = split.src // did this so i can zero it out to test fallback
|
||||
return (
|
||||
<div
|
||||
{...rest}
|
||||
data-component="avatar"
|
||||
data-size={split.size || "normal"}
|
||||
data-has-image={src ? "" : undefined}
|
||||
classList={{
|
||||
...split.classList,
|
||||
[split.class ?? ""]: !!split.class,
|
||||
}}
|
||||
style={{
|
||||
...(typeof split.style === "object" ? split.style : {}),
|
||||
...(!src && split.background ? { "--avatar-bg": split.background } : {}),
|
||||
...(!src && split.foreground ? { "--avatar-fg": split.foreground } : {}),
|
||||
}}
|
||||
>
|
||||
<Show when={src} fallback={first(split.fallback)}>
|
||||
{(src) => <img src={src()} draggable={false} data-slot="avatar-image" />}
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
259
qimingcode/packages/ui/src/components/basic-tool.css
Normal file
259
qimingcode/packages/ui/src/components/basic-tool.css
Normal file
@@ -0,0 +1,259 @@
|
||||
[data-component="tool-trigger"] {
|
||||
content-visibility: auto;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-self: stretch;
|
||||
gap: 0px;
|
||||
justify-content: flex-start;
|
||||
|
||||
&[data-clickable="true"] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&[data-hide-details="true"] {
|
||||
[data-slot="basic-tool-tool-trigger-content"] {
|
||||
flex: 1 1 auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
[data-slot="basic-tool-tool-info"] {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="basic-tool-tool-trigger-content"] {
|
||||
flex: 0 1 auto;
|
||||
width: auto;
|
||||
max-width: calc(100% - 24px);
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-self: stretch;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
[data-slot="basic-tool-tool-indicator"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
|
||||
[data-component="spinner"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="basic-tool-tool-spinner"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
color: var(--text-weak);
|
||||
|
||||
[data-component="spinner"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="icon-svg"] {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
[data-slot="basic-tool-tool-info"] {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
[data-slot="basic-tool-tool-info-structured"] {
|
||||
flex: 0 1 auto;
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
[data-slot="basic-tool-tool-info-main"] {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
[data-slot="basic-tool-tool-title"] {
|
||||
flex-shrink: 0;
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: var(--line-height-large);
|
||||
letter-spacing: var(--letter-spacing-normal);
|
||||
color: var(--text-strong);
|
||||
|
||||
&.capitalize {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
&.agent-title {
|
||||
color: var(--text-strong);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="basic-tool-tool-subtitle"] {
|
||||
flex-shrink: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: var(--font-family-sans);
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-regular);
|
||||
line-height: var(--line-height-large);
|
||||
letter-spacing: var(--letter-spacing-normal);
|
||||
color: var(--text-base);
|
||||
|
||||
&.clickable {
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
transition: color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--text-base);
|
||||
}
|
||||
}
|
||||
|
||||
&.subagent-link {
|
||||
color: var(--text-interactive-base);
|
||||
text-decoration: none;
|
||||
text-underline-offset: 2px;
|
||||
font-weight: var(--font-weight-regular);
|
||||
|
||||
&:hover {
|
||||
color: var(--text-interactive-base);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
&:active {
|
||||
color: var(--text-interactive-base);
|
||||
}
|
||||
|
||||
&:visited {
|
||||
color: var(--text-interactive-base);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="basic-tool-tool-arg"] {
|
||||
flex-shrink: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: var(--font-family-sans);
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-regular);
|
||||
line-height: var(--line-height-large);
|
||||
letter-spacing: var(--letter-spacing-normal);
|
||||
color: var(--text-base);
|
||||
}
|
||||
|
||||
[data-slot="basic-tool-tool-action"] {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="task-tool-card"] {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border-weak-base, rgba(255, 255, 255, 0.08));
|
||||
background: color-mix(in srgb, var(--background-base) 92%, transparent);
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
background-color 0.15s ease,
|
||||
color 0.15s ease;
|
||||
|
||||
[data-slot="basic-tool-tool-info-structured"] {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
[data-slot="basic-tool-tool-info-main"] {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
[data-component="task-tool-spinner"] {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
|
||||
[data-component="spinner"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="task-tool-action"] {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
color: var(--icon-weak);
|
||||
margin-left: auto;
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity 0.15s ease,
|
||||
color 0.15s ease;
|
||||
}
|
||||
|
||||
[data-component="task-tool-title"] {
|
||||
flex-shrink: 0;
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: var(--line-height-large);
|
||||
letter-spacing: var(--letter-spacing-normal);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
[data-slot="basic-tool-tool-subtitle"] {
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
border-color: var(--border-weak-base, rgba(255, 255, 255, 0.08));
|
||||
background: color-mix(in srgb, var(--background-stronger) 88%, transparent);
|
||||
|
||||
[data-component="task-tool-action"] {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
133
qimingcode/packages/ui/src/components/basic-tool.stories.tsx
Normal file
133
qimingcode/packages/ui/src/components/basic-tool.stories.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
// @ts-nocheck
|
||||
import { createSignal } from "solid-js"
|
||||
import * as mod from "./basic-tool"
|
||||
import { create } from "../storybook/scaffold"
|
||||
|
||||
const docs = `### Overview
|
||||
Expandable tool panel with a structured trigger and optional details.
|
||||
|
||||
Use structured triggers for consistent layout; custom triggers allowed.
|
||||
|
||||
### API
|
||||
- Required: \`icon\` and \`trigger\` (structured or custom JSX).
|
||||
- Optional: \`status\`, \`defaultOpen\`, \`forceOpen\`, \`defer\`, \`locked\`.
|
||||
|
||||
### Variants and states
|
||||
- Pending/running status animates the title via TextShimmer.
|
||||
|
||||
### Behavior
|
||||
- Uses Collapsible; can defer content rendering until open.
|
||||
- Locked state prevents closing.
|
||||
|
||||
### Accessibility
|
||||
- TODO: confirm trigger semantics and aria labeling.
|
||||
|
||||
### Theming/tokens
|
||||
- Uses \`data-component="tool-trigger"\` and related slots.
|
||||
|
||||
`
|
||||
|
||||
const story = create({
|
||||
title: "UI/Basic Tool",
|
||||
mod,
|
||||
args: {
|
||||
icon: "mcp",
|
||||
defaultOpen: true,
|
||||
trigger: {
|
||||
title: "Basic Tool",
|
||||
subtitle: "Example subtitle",
|
||||
args: ["--flag", "value"],
|
||||
},
|
||||
children: "Details content",
|
||||
},
|
||||
})
|
||||
|
||||
export default {
|
||||
title: "UI/Basic Tool",
|
||||
id: "components-basic-tool",
|
||||
component: story.meta.component,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: docs,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const Basic = story.Basic
|
||||
|
||||
export const Pending = {
|
||||
args: {
|
||||
status: "pending",
|
||||
trigger: {
|
||||
title: "Running tool",
|
||||
subtitle: "Working...",
|
||||
},
|
||||
children: "Progress details",
|
||||
},
|
||||
}
|
||||
|
||||
export const Locked = {
|
||||
args: {
|
||||
locked: true,
|
||||
trigger: {
|
||||
title: "Locked tool",
|
||||
subtitle: "Cannot close",
|
||||
},
|
||||
children: "Locked details",
|
||||
},
|
||||
}
|
||||
|
||||
export const Deferred = {
|
||||
args: {
|
||||
defer: true,
|
||||
defaultOpen: false,
|
||||
trigger: {
|
||||
title: "Deferred tool",
|
||||
subtitle: "Content mounts on open",
|
||||
},
|
||||
children: "Deferred content",
|
||||
},
|
||||
}
|
||||
|
||||
export const ForceOpen = {
|
||||
args: {
|
||||
forceOpen: true,
|
||||
trigger: {
|
||||
title: "Forced open",
|
||||
subtitle: "Cannot close",
|
||||
},
|
||||
children: "Forced content",
|
||||
},
|
||||
}
|
||||
|
||||
export const HideDetails = {
|
||||
args: {
|
||||
hideDetails: true,
|
||||
trigger: {
|
||||
title: "Summary only",
|
||||
subtitle: "Details hidden",
|
||||
},
|
||||
children: "Hidden content",
|
||||
},
|
||||
}
|
||||
|
||||
export const SubtitleAction = {
|
||||
render: () => {
|
||||
const [message, setMessage] = createSignal("Subtitle not clicked")
|
||||
return (
|
||||
<div style={{ display: "grid", gap: "8px" }}>
|
||||
<div style={{ "font-size": "12px", color: "var(--text-weak)" }}>{message()}</div>
|
||||
<mod.BasicTool
|
||||
icon="mcp"
|
||||
trigger={{ title: "Clickable subtitle", subtitle: "Click me" }}
|
||||
onSubtitleClick={() => setMessage("Subtitle clicked")}
|
||||
>
|
||||
Subtitle action details
|
||||
</mod.BasicTool>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
283
qimingcode/packages/ui/src/components/basic-tool.tsx
Normal file
283
qimingcode/packages/ui/src/components/basic-tool.tsx
Normal file
@@ -0,0 +1,283 @@
|
||||
import { createEffect, For, Match, on, onCleanup, Show, Switch, type JSX } from "solid-js"
|
||||
import { animate, type AnimationPlaybackControls } from "motion"
|
||||
import { useI18n } from "../context/i18n"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Collapsible } from "./collapsible"
|
||||
import type { IconProps } from "./icon"
|
||||
import { TextShimmer } from "./text-shimmer"
|
||||
|
||||
export type TriggerTitle = {
|
||||
title: string
|
||||
titleClass?: string
|
||||
subtitle?: string
|
||||
subtitleClass?: string
|
||||
args?: string[]
|
||||
argsClass?: string
|
||||
action?: JSX.Element
|
||||
}
|
||||
|
||||
const isTriggerTitle = (val: any): val is TriggerTitle => {
|
||||
return (
|
||||
typeof val === "object" && val !== null && "title" in val && (typeof Node === "undefined" || !(val instanceof Node))
|
||||
)
|
||||
}
|
||||
|
||||
export interface BasicToolProps {
|
||||
icon: IconProps["name"]
|
||||
trigger: TriggerTitle | JSX.Element
|
||||
children?: JSX.Element
|
||||
status?: string
|
||||
hideDetails?: boolean
|
||||
defaultOpen?: boolean
|
||||
forceOpen?: boolean
|
||||
defer?: boolean
|
||||
locked?: boolean
|
||||
animated?: boolean
|
||||
onSubtitleClick?: () => void
|
||||
onTriggerClick?: JSX.EventHandlerUnion<HTMLElement, MouseEvent>
|
||||
triggerHref?: string
|
||||
clickable?: boolean
|
||||
}
|
||||
|
||||
const SPRING = { type: "spring" as const, visualDuration: 0.35, bounce: 0 }
|
||||
|
||||
export function BasicTool(props: BasicToolProps) {
|
||||
const [state, setState] = createStore({
|
||||
open: props.defaultOpen ?? false,
|
||||
ready: props.defaultOpen ?? false,
|
||||
})
|
||||
const open = () => state.open
|
||||
const ready = () => state.ready
|
||||
const pending = () => props.status === "pending" || props.status === "running"
|
||||
|
||||
let frame: number | undefined
|
||||
|
||||
const cancel = () => {
|
||||
if (frame === undefined) return
|
||||
cancelAnimationFrame(frame)
|
||||
frame = undefined
|
||||
}
|
||||
|
||||
onCleanup(cancel)
|
||||
|
||||
createEffect(() => {
|
||||
if (props.forceOpen) setState("open", true)
|
||||
})
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
open,
|
||||
(value) => {
|
||||
if (!props.defer) return
|
||||
if (!value) {
|
||||
cancel()
|
||||
setState("ready", false)
|
||||
return
|
||||
}
|
||||
|
||||
cancel()
|
||||
frame = requestAnimationFrame(() => {
|
||||
frame = undefined
|
||||
if (!open()) return
|
||||
setState("ready", true)
|
||||
})
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
// Animated height for collapsible open/close
|
||||
let contentRef: HTMLDivElement | undefined
|
||||
let heightAnim: AnimationPlaybackControls | undefined
|
||||
const initialOpen = open()
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
open,
|
||||
(isOpen) => {
|
||||
if (!props.animated || !contentRef) return
|
||||
heightAnim?.stop()
|
||||
if (isOpen) {
|
||||
contentRef.style.overflow = "hidden"
|
||||
heightAnim = animate(contentRef, { height: "auto" }, SPRING)
|
||||
void heightAnim.finished.then(() => {
|
||||
if (!contentRef || !open()) return
|
||||
contentRef.style.overflow = "visible"
|
||||
contentRef.style.height = "auto"
|
||||
})
|
||||
} else {
|
||||
contentRef.style.overflow = "hidden"
|
||||
heightAnim = animate(contentRef, { height: "0px" }, SPRING)
|
||||
}
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
onCleanup(() => {
|
||||
heightAnim?.stop()
|
||||
})
|
||||
|
||||
const handleOpenChange = (value: boolean) => {
|
||||
if (pending()) return
|
||||
if (props.locked && !value) return
|
||||
setState("open", value)
|
||||
}
|
||||
|
||||
const trigger = () => (
|
||||
<div
|
||||
data-component="tool-trigger"
|
||||
data-clickable={props.clickable ? "true" : undefined}
|
||||
data-hide-details={props.hideDetails ? "true" : undefined}
|
||||
>
|
||||
<div data-slot="basic-tool-tool-trigger-content">
|
||||
<div data-slot="basic-tool-tool-info">
|
||||
<Switch>
|
||||
<Match when={isTriggerTitle(props.trigger) && props.trigger}>
|
||||
{(title) => (
|
||||
<div data-slot="basic-tool-tool-info-structured">
|
||||
<div data-slot="basic-tool-tool-info-main">
|
||||
<span
|
||||
data-slot="basic-tool-tool-title"
|
||||
classList={{
|
||||
[title().titleClass ?? ""]: !!title().titleClass,
|
||||
}}
|
||||
>
|
||||
<TextShimmer text={title().title} active={pending()} />
|
||||
</span>
|
||||
<Show when={!pending()}>
|
||||
<Show when={title().subtitle}>
|
||||
<span
|
||||
data-slot="basic-tool-tool-subtitle"
|
||||
classList={{
|
||||
[title().subtitleClass ?? ""]: !!title().subtitleClass,
|
||||
clickable: !!props.onSubtitleClick,
|
||||
}}
|
||||
onClick={(e) => {
|
||||
if (props.onSubtitleClick) {
|
||||
e.stopPropagation()
|
||||
props.onSubtitleClick()
|
||||
}
|
||||
}}
|
||||
>
|
||||
{title().subtitle}
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={title().args?.length}>
|
||||
<For each={title().args}>
|
||||
{(arg) => (
|
||||
<span
|
||||
data-slot="basic-tool-tool-arg"
|
||||
classList={{
|
||||
[title().argsClass ?? ""]: !!title().argsClass,
|
||||
}}
|
||||
>
|
||||
{arg}
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={!pending() && title().action}>
|
||||
<span data-slot="basic-tool-tool-action">{title().action}</span>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={true}>{props.trigger as JSX.Element}</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={props.children && !props.hideDetails && !props.locked && !pending()}>
|
||||
<Collapsible.Arrow />
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<Collapsible open={open()} onOpenChange={handleOpenChange} class="tool-collapsible">
|
||||
<Show
|
||||
when={props.triggerHref}
|
||||
fallback={
|
||||
<Collapsible.Trigger
|
||||
data-hide-details={props.hideDetails ? "true" : undefined}
|
||||
onClick={props.onTriggerClick}
|
||||
>
|
||||
{trigger()}
|
||||
</Collapsible.Trigger>
|
||||
}
|
||||
>
|
||||
{(href) => (
|
||||
<Collapsible.Trigger
|
||||
as="a"
|
||||
href={href()}
|
||||
data-hide-details={props.hideDetails ? "true" : undefined}
|
||||
onClick={props.onTriggerClick}
|
||||
>
|
||||
{trigger()}
|
||||
</Collapsible.Trigger>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={props.animated && props.children && !props.hideDetails}>
|
||||
<div
|
||||
ref={contentRef}
|
||||
data-slot="collapsible-content"
|
||||
data-animated
|
||||
style={{
|
||||
height: initialOpen ? "auto" : "0px",
|
||||
overflow: initialOpen ? "visible" : "hidden",
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={!props.animated && props.children && !props.hideDetails}>
|
||||
<Collapsible.Content>
|
||||
<Show when={!props.defer || ready()}>{props.children}</Show>
|
||||
</Collapsible.Content>
|
||||
</Show>
|
||||
</Collapsible>
|
||||
)
|
||||
}
|
||||
|
||||
function label(input: Record<string, unknown> | undefined) {
|
||||
const keys = ["description", "query", "url", "filePath", "path", "pattern", "name"]
|
||||
return keys.map((key) => input?.[key]).find((value): value is string => typeof value === "string" && value.length > 0)
|
||||
}
|
||||
|
||||
function args(input: Record<string, unknown> | undefined) {
|
||||
if (!input) return []
|
||||
const skip = new Set(["description", "query", "url", "filePath", "path", "pattern", "name"])
|
||||
return Object.entries(input)
|
||||
.filter(([key]) => !skip.has(key))
|
||||
.flatMap(([key, value]) => {
|
||||
if (typeof value === "string") return [`${key}=${value}`]
|
||||
if (typeof value === "number") return [`${key}=${value}`]
|
||||
if (typeof value === "boolean") return [`${key}=${value}`]
|
||||
return []
|
||||
})
|
||||
.slice(0, 3)
|
||||
}
|
||||
|
||||
export function GenericTool(props: {
|
||||
tool: string
|
||||
status?: string
|
||||
hideDetails?: boolean
|
||||
input?: Record<string, unknown>
|
||||
}) {
|
||||
const i18n = useI18n()
|
||||
|
||||
return (
|
||||
<BasicTool
|
||||
icon="mcp"
|
||||
status={props.status}
|
||||
trigger={{
|
||||
title: i18n.t("ui.basicTool.called", { tool: props.tool }),
|
||||
subtitle: label(props.input),
|
||||
args: args(props.input),
|
||||
}}
|
||||
hideDetails={props.hideDetails}
|
||||
/>
|
||||
)
|
||||
}
|
||||
194
qimingcode/packages/ui/src/components/button.css
Normal file
194
qimingcode/packages/ui/src/components/button.css
Normal file
@@ -0,0 +1,194 @@
|
||||
[data-component="button"] {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-style: solid;
|
||||
border-width: 1px;
|
||||
border-radius: var(--radius-md);
|
||||
text-decoration: none;
|
||||
user-select: none;
|
||||
cursor: default;
|
||||
outline: none;
|
||||
white-space: nowrap;
|
||||
|
||||
&[data-variant="primary"] {
|
||||
background-color: var(--button-primary-base);
|
||||
border-color: var(--border-weak-base);
|
||||
color: var(--icon-invert-base);
|
||||
|
||||
[data-slot="icon-svg"] {
|
||||
color: var(--icon-invert-base);
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: var(--icon-strong-hover);
|
||||
}
|
||||
&:focus:not(:disabled) {
|
||||
background-color: var(--icon-strong-focus);
|
||||
}
|
||||
&:active:not(:disabled) {
|
||||
background-color: var(--icon-strong-active);
|
||||
}
|
||||
&:disabled {
|
||||
background-color: var(--icon-strong-disabled);
|
||||
|
||||
[data-slot="icon-svg"] {
|
||||
color: var(--icon-invert-base);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&[data-variant="ghost"] {
|
||||
border-color: transparent;
|
||||
background-color: transparent;
|
||||
color: var(--text-strong);
|
||||
|
||||
[data-slot="icon-svg"] {
|
||||
color: var(--icon-base);
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: var(--surface-base-hover);
|
||||
}
|
||||
&:focus-visible:not(:disabled) {
|
||||
background-color: var(--surface-base-hover);
|
||||
}
|
||||
&:active:not(:disabled) {
|
||||
background-color: var(--surface-base-active);
|
||||
}
|
||||
&:disabled {
|
||||
color: var(--text-weak);
|
||||
cursor: not-allowed;
|
||||
|
||||
[data-slot="icon-svg"] {
|
||||
color: var(--icon-disabled);
|
||||
}
|
||||
}
|
||||
&[data-selected="true"]:not(:disabled) {
|
||||
background-color: var(--surface-base-hover);
|
||||
}
|
||||
&[data-active="true"] {
|
||||
background-color: var(--surface-base-active);
|
||||
}
|
||||
}
|
||||
|
||||
&[data-variant="secondary"] {
|
||||
border: transparent;
|
||||
background-color: var(--button-secondary-base);
|
||||
color: var(--text-strong);
|
||||
box-shadow: var(--shadow-xs-border-base);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: var(--button-secondary-hover);
|
||||
}
|
||||
&:focus:not(:disabled) {
|
||||
background-color: var(--button-secondary-base);
|
||||
}
|
||||
&:focus-visible:not(:active) {
|
||||
background-color: var(--button-secondary-base);
|
||||
box-shadow: var(--shadow-xs-border-focus);
|
||||
}
|
||||
&:focus-visible:active {
|
||||
box-shadow: none;
|
||||
}
|
||||
&:active:not(:disabled) {
|
||||
background-color: var(--button-secondary-base);
|
||||
}
|
||||
&:disabled {
|
||||
border-color: var(--border-disabled);
|
||||
background-color: var(--surface-disabled);
|
||||
color: var(--text-weak);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
[data-slot="icon-svg"] {
|
||||
color: var(--icon-base);
|
||||
}
|
||||
}
|
||||
|
||||
&[data-size="small"] {
|
||||
height: 24px;
|
||||
padding: 0 8px;
|
||||
&[data-icon] {
|
||||
padding: 0 12px 0 4px;
|
||||
}
|
||||
|
||||
font-size: var(--font-size-small);
|
||||
line-height: var(--line-height-large);
|
||||
gap: 8px;
|
||||
|
||||
/* text-12-medium */
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-small);
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: var(--line-height-large); /* 166.667% */
|
||||
letter-spacing: var(--letter-spacing-normal);
|
||||
}
|
||||
|
||||
&[data-size="normal"] {
|
||||
height: 28px;
|
||||
line-height: 28px;
|
||||
padding: 0 6px;
|
||||
&[data-icon] {
|
||||
padding: 0 12px 0 4px;
|
||||
}
|
||||
|
||||
font-size: var(--font-size-small);
|
||||
gap: 8px;
|
||||
|
||||
/* text-12-medium */
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-small);
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-medium);
|
||||
letter-spacing: var(--letter-spacing-normal);
|
||||
}
|
||||
|
||||
&[data-size="large"] {
|
||||
height: 32px;
|
||||
padding: 6px 12px;
|
||||
|
||||
&[data-icon] {
|
||||
padding: 0 12px 0 8px;
|
||||
}
|
||||
|
||||
gap: 8px;
|
||||
|
||||
/* text-14-medium */
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: var(--line-height-large); /* 142.857% */
|
||||
letter-spacing: var(--letter-spacing-normal);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="button"].titlebar-icon[data-variant="ghost"][aria-expanded="true"] {
|
||||
background-color: var(--surface-base-active);
|
||||
}
|
||||
|
||||
[data-component="button"].titlebar-icon[data-variant="ghost"][aria-expanded="true"] [data-slot="icon-svg"] {
|
||||
color: var(--icon-strong-base);
|
||||
}
|
||||
|
||||
[data-component="button"].titlebar-icon[data-variant="ghost"][aria-expanded="true"]:hover:not(:disabled) {
|
||||
background-color: var(--surface-base-active);
|
||||
}
|
||||
|
||||
[data-component="button"].titlebar-icon[data-variant="ghost"][aria-current="page"] {
|
||||
background-color: var(--surface-base-active);
|
||||
}
|
||||
|
||||
[data-component="button"].titlebar-icon[data-variant="ghost"][aria-current="page"] [data-slot="icon-svg"] {
|
||||
color: var(--icon-strong-base);
|
||||
}
|
||||
|
||||
[data-component="button"].titlebar-icon[data-variant="ghost"][aria-current="page"]:hover:not(:disabled) {
|
||||
background-color: var(--surface-base-active);
|
||||
}
|
||||
108
qimingcode/packages/ui/src/components/button.stories.tsx
Normal file
108
qimingcode/packages/ui/src/components/button.stories.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
// @ts-nocheck
|
||||
import { Button } from "./button"
|
||||
|
||||
const docs = `### Overview
|
||||
Primary action button with size, variant, and optional icon support.
|
||||
|
||||
Use \`IconButton\` for icon-only actions.
|
||||
|
||||
### API
|
||||
- \`variant\`: "primary" | "secondary" | "ghost".
|
||||
- \`size\`: "small" | "normal" | "large".
|
||||
- \`icon\`: Icon name for a leading icon.
|
||||
- Inherits Kobalte Button props and native button attributes.
|
||||
|
||||
### Variants and states
|
||||
- Variants: primary, secondary, ghost.
|
||||
- States: disabled.
|
||||
|
||||
### Behavior
|
||||
- Renders an Icon when \`icon\` is set.
|
||||
|
||||
### Accessibility
|
||||
- Provide clear label text; use \`aria-label\` for icon-only buttons.
|
||||
|
||||
### Theming/tokens
|
||||
- Uses \`data-component="button"\` with size/variant data attributes.
|
||||
|
||||
`
|
||||
|
||||
export default {
|
||||
title: "UI/Button",
|
||||
id: "components-button",
|
||||
component: Button,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: docs,
|
||||
},
|
||||
},
|
||||
},
|
||||
args: {
|
||||
children: "Button",
|
||||
variant: "secondary",
|
||||
size: "normal",
|
||||
},
|
||||
argTypes: {
|
||||
variant: {
|
||||
control: "select",
|
||||
options: ["primary", "secondary", "ghost"],
|
||||
},
|
||||
size: {
|
||||
control: "select",
|
||||
options: ["small", "normal", "large"],
|
||||
},
|
||||
icon: {
|
||||
control: "select",
|
||||
options: ["none", "check", "plus", "arrow-right"],
|
||||
mapping: {
|
||||
none: undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const Primary = {
|
||||
args: {
|
||||
variant: "primary",
|
||||
},
|
||||
}
|
||||
|
||||
export const Secondary = {}
|
||||
|
||||
export const Ghost = {
|
||||
args: {
|
||||
variant: "ghost",
|
||||
},
|
||||
}
|
||||
|
||||
export const WithIcon = {
|
||||
args: {
|
||||
children: "Continue",
|
||||
icon: "arrow-right",
|
||||
},
|
||||
}
|
||||
|
||||
export const Disabled = {
|
||||
args: {
|
||||
variant: "primary",
|
||||
disabled: true,
|
||||
},
|
||||
}
|
||||
|
||||
export const Sizes = {
|
||||
render: () => (
|
||||
<div style={{ display: "flex", gap: "12px", "align-items": "center" }}>
|
||||
<Button size="small" variant="secondary">
|
||||
Small
|
||||
</Button>
|
||||
<Button size="normal" variant="secondary">
|
||||
Normal
|
||||
</Button>
|
||||
<Button size="large" variant="secondary">
|
||||
Large
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
33
qimingcode/packages/ui/src/components/button.tsx
Normal file
33
qimingcode/packages/ui/src/components/button.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Button as Kobalte } from "@kobalte/core/button"
|
||||
import { type ComponentProps, Show, splitProps } from "solid-js"
|
||||
import { Icon, IconProps } from "./icon"
|
||||
|
||||
export interface ButtonProps
|
||||
extends ComponentProps<typeof Kobalte>,
|
||||
Pick<ComponentProps<"button">, "class" | "classList" | "children"> {
|
||||
size?: "small" | "normal" | "large"
|
||||
variant?: "primary" | "secondary" | "ghost"
|
||||
icon?: IconProps["name"]
|
||||
}
|
||||
|
||||
export function Button(props: ButtonProps) {
|
||||
const [split, rest] = splitProps(props, ["variant", "size", "icon", "class", "classList"])
|
||||
return (
|
||||
<Kobalte
|
||||
{...rest}
|
||||
data-component="button"
|
||||
data-size={split.size || "normal"}
|
||||
data-variant={split.variant || "secondary"}
|
||||
data-icon={split.icon}
|
||||
classList={{
|
||||
...split.classList,
|
||||
[split.class ?? ""]: !!split.class,
|
||||
}}
|
||||
>
|
||||
<Show when={split.icon}>
|
||||
<Icon name={split.icon!} size="small" />
|
||||
</Show>
|
||||
{props.children}
|
||||
</Kobalte>
|
||||
)
|
||||
}
|
||||
94
qimingcode/packages/ui/src/components/card.css
Normal file
94
qimingcode/packages/ui/src/components/card.css
Normal file
@@ -0,0 +1,94 @@
|
||||
[data-component="card"] {
|
||||
--card-pad-y: 10px;
|
||||
--card-pad-r: 12px;
|
||||
--card-pad-l: 10px;
|
||||
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--card-pad-y) var(--card-pad-r) var(--card-pad-y) var(--card-pad-l);
|
||||
|
||||
/* text-14-regular */
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-base);
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-regular);
|
||||
line-height: var(--line-height-large);
|
||||
letter-spacing: var(--letter-spacing-normal);
|
||||
color: var(--text-strong);
|
||||
|
||||
--card-gap: 8px;
|
||||
--card-icon: 16px;
|
||||
--card-indent: 0px;
|
||||
--card-line-pad: 8px;
|
||||
|
||||
--card-accent: var(--icon-active);
|
||||
|
||||
&:has([data-slot="card-title"]) {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&:has([data-slot="card-title-icon"]) {
|
||||
--card-indent: calc(var(--card-icon) + var(--card-gap));
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: var(--card-line-pad);
|
||||
bottom: var(--card-line-pad);
|
||||
width: 2px;
|
||||
border-radius: 2px;
|
||||
background-color: var(--card-accent);
|
||||
}
|
||||
|
||||
:where([data-card="title"], [data-slot="card-title"]) {
|
||||
color: var(--text-strong);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
:where([data-slot="card-title"]) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--card-gap);
|
||||
}
|
||||
|
||||
:where([data-slot="card-title"]) [data-component="icon"] {
|
||||
color: var(--card-accent);
|
||||
}
|
||||
|
||||
:where([data-slot="card-title-icon"]) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--card-icon);
|
||||
height: var(--card-icon);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
:where([data-slot="card-title-icon"][data-placeholder]) [data-component="icon"] {
|
||||
color: var(--text-weak);
|
||||
}
|
||||
|
||||
:where([data-slot="card-title-icon"])
|
||||
[data-slot="icon-svg"]
|
||||
:is(path, line, polyline, polygon, rect, circle, ellipse)[stroke] {
|
||||
stroke-width: 1.5px !important;
|
||||
}
|
||||
|
||||
:where([data-card="description"], [data-slot="card-description"]) {
|
||||
color: var(--text-base);
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
:where([data-card="actions"], [data-slot="card-actions"]) {
|
||||
padding-left: var(--card-indent);
|
||||
}
|
||||
}
|
||||
88
qimingcode/packages/ui/src/components/card.stories.tsx
Normal file
88
qimingcode/packages/ui/src/components/card.stories.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
// @ts-nocheck
|
||||
import { Card, CardActions, CardDescription, CardTitle } from "./card"
|
||||
import { Button } from "./button"
|
||||
|
||||
const docs = `### Overview
|
||||
Surface container for grouping related content and actions.
|
||||
|
||||
Pair with \`Button\` or \`Tag\` for quick actions.
|
||||
|
||||
### API
|
||||
- Optional: \`variant\` (normal, error, warning, success, info).
|
||||
- Accepts standard div props.
|
||||
|
||||
### Variants and states
|
||||
- Semantic variants for status-driven messaging.
|
||||
|
||||
### Behavior
|
||||
- Pure presentational container.
|
||||
|
||||
### Accessibility
|
||||
- Provide headings or aria labels when used in isolation.
|
||||
|
||||
### Theming/tokens
|
||||
- Uses \`data-component="card"\` with variant data attributes.
|
||||
|
||||
`
|
||||
|
||||
export default {
|
||||
title: "UI/Card",
|
||||
id: "components-card",
|
||||
component: Card,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: docs,
|
||||
},
|
||||
},
|
||||
},
|
||||
args: {
|
||||
variant: "normal",
|
||||
},
|
||||
argTypes: {
|
||||
variant: {
|
||||
control: "select",
|
||||
options: ["normal", "error", "warning", "success", "info"],
|
||||
},
|
||||
},
|
||||
render: (props: { variant?: "normal" | "error" | "warning" | "success" | "info" }) => {
|
||||
return (
|
||||
<Card variant={props.variant}>
|
||||
<CardTitle variant={props.variant}>Card title</CardTitle>
|
||||
<CardDescription>Small supporting text.</CardDescription>
|
||||
<CardActions>
|
||||
<Button size="small" variant="secondary">
|
||||
Action
|
||||
</Button>
|
||||
</CardActions>
|
||||
</Card>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
export const Normal = {}
|
||||
|
||||
export const Error = {
|
||||
args: {
|
||||
variant: "error",
|
||||
},
|
||||
}
|
||||
|
||||
export const Warning = {
|
||||
args: {
|
||||
variant: "warning",
|
||||
},
|
||||
}
|
||||
|
||||
export const Success = {
|
||||
args: {
|
||||
variant: "success",
|
||||
},
|
||||
}
|
||||
|
||||
export const Info = {
|
||||
args: {
|
||||
variant: "info",
|
||||
},
|
||||
}
|
||||
123
qimingcode/packages/ui/src/components/card.tsx
Normal file
123
qimingcode/packages/ui/src/components/card.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import { type ComponentProps, splitProps } from "solid-js"
|
||||
import { Icon, type IconProps } from "./icon"
|
||||
|
||||
type Variant = "normal" | "error" | "warning" | "success" | "info"
|
||||
|
||||
export interface CardProps extends ComponentProps<"div"> {
|
||||
variant?: Variant
|
||||
}
|
||||
|
||||
export interface CardTitleProps extends ComponentProps<"div"> {
|
||||
variant?: Variant
|
||||
|
||||
/**
|
||||
* Optional title icon.
|
||||
*
|
||||
* - `undefined`: picks a default icon based on `variant` (error/warning/success/info)
|
||||
* - `false`/`null`: disables the icon
|
||||
* - `Icon` name: forces a specific icon
|
||||
*/
|
||||
icon?: IconProps["name"] | false | null
|
||||
}
|
||||
|
||||
function pick(variant: Variant) {
|
||||
if (variant === "error") return "circle-ban-sign" as const
|
||||
if (variant === "warning") return "warning" as const
|
||||
if (variant === "success") return "circle-check" as const
|
||||
if (variant === "info") return "help" as const
|
||||
return
|
||||
}
|
||||
|
||||
function mix(style: ComponentProps<"div">["style"], value?: string) {
|
||||
if (!value) return style
|
||||
if (!style) return { "--card-accent": value }
|
||||
if (typeof style === "string") return `${style};--card-accent:${value};`
|
||||
return { ...(style as Record<string, string | number>), "--card-accent": value }
|
||||
}
|
||||
|
||||
export function Card(props: CardProps) {
|
||||
const [split, rest] = splitProps(props, ["variant", "style", "class", "classList"])
|
||||
const variant = () => split.variant ?? "normal"
|
||||
const accent = () => {
|
||||
const v = variant()
|
||||
if (v === "error") return "var(--icon-critical-base)"
|
||||
if (v === "warning") return "var(--icon-warning-active)"
|
||||
if (v === "success") return "var(--icon-success-active)"
|
||||
if (v === "info") return "var(--icon-info-active)"
|
||||
return
|
||||
}
|
||||
return (
|
||||
<div
|
||||
{...rest}
|
||||
data-component="card"
|
||||
data-variant={variant()}
|
||||
style={mix(split.style, accent())}
|
||||
classList={{
|
||||
...split.classList,
|
||||
[split.class ?? ""]: !!split.class,
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function CardTitle(props: CardTitleProps) {
|
||||
const [split, rest] = splitProps(props, ["variant", "icon", "class", "classList", "children"])
|
||||
const show = () => split.icon !== false && split.icon !== null
|
||||
const name = () => {
|
||||
if (split.icon === false || split.icon === null) return
|
||||
if (typeof split.icon === "string") return split.icon
|
||||
return pick(split.variant ?? "normal")
|
||||
}
|
||||
const placeholder = () => !name()
|
||||
return (
|
||||
<div
|
||||
{...rest}
|
||||
data-slot="card-title"
|
||||
classList={{
|
||||
...split.classList,
|
||||
[split.class ?? ""]: !!split.class,
|
||||
}}
|
||||
>
|
||||
{show() ? (
|
||||
<span data-slot="card-title-icon" data-placeholder={placeholder() || undefined}>
|
||||
<Icon name={name() ?? "dash"} size="small" />
|
||||
</span>
|
||||
) : null}
|
||||
{split.children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function CardDescription(props: ComponentProps<"div">) {
|
||||
const [split, rest] = splitProps(props, ["class", "classList", "children"])
|
||||
return (
|
||||
<div
|
||||
{...rest}
|
||||
data-slot="card-description"
|
||||
classList={{
|
||||
...split.classList,
|
||||
[split.class ?? ""]: !!split.class,
|
||||
}}
|
||||
>
|
||||
{split.children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function CardActions(props: ComponentProps<"div">) {
|
||||
const [split, rest] = splitProps(props, ["class", "classList", "children"])
|
||||
return (
|
||||
<div
|
||||
{...rest}
|
||||
data-slot="card-actions"
|
||||
classList={{
|
||||
...split.classList,
|
||||
[split.class ?? ""]: !!split.class,
|
||||
}}
|
||||
>
|
||||
{split.children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
131
qimingcode/packages/ui/src/components/checkbox.css
Normal file
131
qimingcode/packages/ui/src/components/checkbox.css
Normal file
@@ -0,0 +1,131 @@
|
||||
[data-component="checkbox"] {
|
||||
display: flex;
|
||||
align-items: var(--checkbox-align, center);
|
||||
gap: 12px;
|
||||
cursor: default;
|
||||
|
||||
[data-slot="checkbox-checkbox-input"] {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border-width: 0;
|
||||
}
|
||||
|
||||
[data-slot="checkbox-checkbox-control"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
padding: 2px;
|
||||
margin-top: var(--checkbox-offset, 0px);
|
||||
aspect-ratio: 1;
|
||||
flex-shrink: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border-weak-base);
|
||||
transition:
|
||||
border-color 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)),
|
||||
background-color 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)),
|
||||
box-shadow 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1));
|
||||
/* background-color: var(--surface-weak); */
|
||||
}
|
||||
|
||||
[data-slot="checkbox-checkbox-indicator"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
color: var(--icon-base);
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
transition:
|
||||
opacity 180ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)),
|
||||
transform 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1));
|
||||
}
|
||||
|
||||
/* [data-slot="checkbox-checkbox-content"] { */
|
||||
/* } */
|
||||
|
||||
[data-slot="checkbox-checkbox-label"] {
|
||||
user-select: none;
|
||||
color: var(--text-base);
|
||||
|
||||
/* text-12-regular */
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-small);
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-regular);
|
||||
line-height: var(--line-height-large); /* 166.667% */
|
||||
letter-spacing: var(--letter-spacing-normal);
|
||||
}
|
||||
|
||||
[data-slot="checkbox-checkbox-description"] {
|
||||
color: var(--text-base);
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: 12px;
|
||||
font-weight: var(--font-weight-regular);
|
||||
line-height: var(--line-height-normal);
|
||||
letter-spacing: var(--letter-spacing-normal);
|
||||
}
|
||||
|
||||
[data-slot="checkbox-checkbox-error"] {
|
||||
color: var(--text-error);
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: 12px;
|
||||
font-weight: var(--font-weight-regular);
|
||||
line-height: var(--line-height-normal);
|
||||
letter-spacing: var(--letter-spacing-normal);
|
||||
}
|
||||
|
||||
&:hover:not([data-disabled], [data-readonly]) [data-slot="checkbox-checkbox-control"] {
|
||||
border-color: var(--border-hover);
|
||||
background-color: var(--surface-hover);
|
||||
}
|
||||
|
||||
&:not([data-readonly]) [data-slot="checkbox-checkbox-input"]:focus-visible + [data-slot="checkbox-checkbox-control"] {
|
||||
border-color: var(--border-focus);
|
||||
box-shadow: var(--shadow-xs-border-focus);
|
||||
}
|
||||
|
||||
&[data-checked] [data-slot="checkbox-checkbox-control"],
|
||||
&[data-indeterminate] [data-slot="checkbox-checkbox-control"] {
|
||||
border-color: var(--border-base);
|
||||
background-color: var(--surface-weak);
|
||||
}
|
||||
|
||||
&[data-checked]:hover:not([data-disabled], [data-readonly]) [data-slot="checkbox-checkbox-control"],
|
||||
&[data-indeterminate]:hover:not([data-disabled]) [data-slot="checkbox-checkbox-control"] {
|
||||
border-color: var(--border-hover);
|
||||
background-color: var(--surface-hover);
|
||||
}
|
||||
|
||||
&[data-checked] [data-slot="checkbox-checkbox-indicator"],
|
||||
&[data-indeterminate] [data-slot="checkbox-checkbox-indicator"] {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
&[data-disabled] {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&[data-disabled] [data-slot="checkbox-checkbox-control"] {
|
||||
border-color: var(--border-disabled);
|
||||
background-color: var(--surface-disabled);
|
||||
}
|
||||
|
||||
&[data-invalid] [data-slot="checkbox-checkbox-control"] {
|
||||
border-color: var(--border-error);
|
||||
}
|
||||
|
||||
&[data-readonly] {
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
71
qimingcode/packages/ui/src/components/checkbox.stories.tsx
Normal file
71
qimingcode/packages/ui/src/components/checkbox.stories.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
// @ts-nocheck
|
||||
import { Icon } from "./icon"
|
||||
import * as mod from "./checkbox"
|
||||
import { create } from "../storybook/scaffold"
|
||||
|
||||
const docs = `### Overview
|
||||
Checkbox control for multi-select or agreement inputs.
|
||||
|
||||
Use in forms and multi-select lists.
|
||||
|
||||
### API
|
||||
- Uses Kobalte Checkbox props (\`checked\`, \`defaultChecked\`, \`onChange\`).
|
||||
- Optional: \`hideLabel\`, \`description\`, \`icon\`.
|
||||
- Children render as the label.
|
||||
|
||||
### Variants and states
|
||||
- Checked/unchecked, indeterminate, disabled (via Kobalte).
|
||||
|
||||
### Behavior
|
||||
- Controlled or uncontrolled usage.
|
||||
|
||||
### Accessibility
|
||||
- TODO: confirm aria attributes from Kobalte.
|
||||
|
||||
### Theming/tokens
|
||||
- Uses \`data-component="checkbox"\` and related slots.
|
||||
|
||||
`
|
||||
|
||||
const story = create({ title: "UI/Checkbox", mod, args: { children: "Checkbox", defaultChecked: true } })
|
||||
export default {
|
||||
title: "UI/Checkbox",
|
||||
id: "components-checkbox",
|
||||
component: story.meta.component,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: docs,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const Basic = story.Basic
|
||||
|
||||
export const States = {
|
||||
render: () => (
|
||||
<div style={{ display: "grid", gap: "12px" }}>
|
||||
<mod.Checkbox defaultChecked>Checked</mod.Checkbox>
|
||||
<mod.Checkbox>Unchecked</mod.Checkbox>
|
||||
<mod.Checkbox disabled>Disabled</mod.Checkbox>
|
||||
<mod.Checkbox description="Helper text">With description</mod.Checkbox>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const CustomIcon = {
|
||||
render: () => (
|
||||
<mod.Checkbox icon={<Icon name="check" size="small" />} defaultChecked>
|
||||
Custom icon
|
||||
</mod.Checkbox>
|
||||
),
|
||||
}
|
||||
|
||||
export const HiddenLabel = {
|
||||
args: {
|
||||
children: "Hidden label",
|
||||
hideLabel: true,
|
||||
},
|
||||
}
|
||||
43
qimingcode/packages/ui/src/components/checkbox.tsx
Normal file
43
qimingcode/packages/ui/src/components/checkbox.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Checkbox as Kobalte } from "@kobalte/core/checkbox"
|
||||
import { Show, splitProps } from "solid-js"
|
||||
import type { ComponentProps, JSX, ParentProps } from "solid-js"
|
||||
|
||||
export interface CheckboxProps extends ParentProps<ComponentProps<typeof Kobalte>> {
|
||||
hideLabel?: boolean
|
||||
description?: string
|
||||
icon?: JSX.Element
|
||||
}
|
||||
|
||||
export function Checkbox(props: CheckboxProps) {
|
||||
const [local, others] = splitProps(props, ["children", "class", "label", "hideLabel", "description", "icon"])
|
||||
return (
|
||||
<Kobalte {...others} data-component="checkbox">
|
||||
<Kobalte.Input data-slot="checkbox-checkbox-input" />
|
||||
<Kobalte.Control data-slot="checkbox-checkbox-control">
|
||||
<Kobalte.Indicator data-slot="checkbox-checkbox-indicator">
|
||||
{local.icon || (
|
||||
<svg viewBox="0 0 12 12" fill="none" width="10" height="10" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M3 7.17905L5.02703 8.85135L9 3.5"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="square"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</Kobalte.Indicator>
|
||||
</Kobalte.Control>
|
||||
<div data-slot="checkbox-checkbox-content">
|
||||
<Show when={props.children}>
|
||||
<Kobalte.Label data-slot="checkbox-checkbox-label" classList={{ "sr-only": local.hideLabel }}>
|
||||
{props.children}
|
||||
</Kobalte.Label>
|
||||
</Show>
|
||||
<Show when={local.description}>
|
||||
<Kobalte.Description data-slot="checkbox-checkbox-description">{local.description}</Kobalte.Description>
|
||||
</Show>
|
||||
<Kobalte.ErrorMessage data-slot="checkbox-checkbox-error" />
|
||||
</div>
|
||||
</Kobalte>
|
||||
)
|
||||
}
|
||||
148
qimingcode/packages/ui/src/components/collapsible.css
Normal file
148
qimingcode/packages/ui/src/components/collapsible.css
Normal file
@@ -0,0 +1,148 @@
|
||||
[data-component="collapsible"] {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
transition: background-color 0.15s ease;
|
||||
border-radius: var(--radius-md);
|
||||
overflow: visible;
|
||||
|
||||
&.tool-collapsible {
|
||||
--tool-content-gap: 4px;
|
||||
gap: var(--tool-content-gap);
|
||||
}
|
||||
|
||||
[data-slot="collapsible-trigger"] {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
align-items: center;
|
||||
align-self: stretch;
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
color: var(--text-base);
|
||||
|
||||
[data-slot="collapsible-arrow"] {
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
will-change: opacity;
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
[data-slot="collapsible-arrow-icon"] {
|
||||
display: inline-flex;
|
||||
color: var(--icon-weaker);
|
||||
transform: translateZ(0) rotate(-90deg);
|
||||
transition: transform 0.15s ease;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
&:hover [data-slot="collapsible-arrow"] {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* text-12-medium */
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-small);
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: var(--line-height-large); /* 166.667% */
|
||||
letter-spacing: var(--letter-spacing-normal);
|
||||
|
||||
/* &:hover { */
|
||||
/* background-color: var(--surface-base); */
|
||||
/* } */
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
background-color: var(--surface-raised-base-hover);
|
||||
}
|
||||
&[data-disabled] {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&[data-hide-details="true"] {
|
||||
height: auto;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
[data-slot="collapsible-arrow"] {
|
||||
flex-shrink: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="collapsible-trigger"][aria-expanded="true"] {
|
||||
[data-slot="collapsible-arrow"] {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
[data-slot="collapsible-arrow-icon"] {
|
||||
transform: translateZ(0) rotate(0deg);
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="collapsible-content"] {
|
||||
overflow: hidden;
|
||||
/* animation: slideUp 250ms ease-out; */
|
||||
|
||||
&[data-expanded] {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* &[data-expanded] { */
|
||||
/* animation: slideDown 250ms ease-out; */
|
||||
/* } */
|
||||
}
|
||||
|
||||
&[data-variant="ghost"] {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
|
||||
> [data-slot="collapsible-trigger"] {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
|
||||
/* &:hover { */
|
||||
/* color: var(--text-strong); */
|
||||
/* } */
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
background-color: var(--surface-raised-base-hover);
|
||||
}
|
||||
&[data-disabled] {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&[data-variant="ghost"][data-scope="filetree"] {
|
||||
> [data-slot="collapsible-trigger"] {
|
||||
height: 24px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
height: 0;
|
||||
}
|
||||
to {
|
||||
height: var(--kb-collapsible-content-height);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
height: var(--kb-collapsible-content-height);
|
||||
}
|
||||
to {
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// @ts-nocheck
|
||||
import * as mod from "./collapsible"
|
||||
|
||||
const docs = `### Overview
|
||||
Toggleable content region with optional arrow indicator.
|
||||
|
||||
Compose \`Collapsible.Trigger\`, \`Collapsible.Content\`, and \`Collapsible.Arrow\`.
|
||||
|
||||
### API
|
||||
- Root accepts Kobalte Collapsible props (\`open\`, \`defaultOpen\`, \`onOpenChange\`).
|
||||
- \`variant\` controls styling ("normal" | "ghost").
|
||||
|
||||
### Variants and states
|
||||
- Normal and ghost variants.
|
||||
- Open/closed states.
|
||||
|
||||
### Behavior
|
||||
- Trigger toggles the content visibility.
|
||||
|
||||
### Accessibility
|
||||
- TODO: confirm ARIA attributes provided by Kobalte.
|
||||
|
||||
### Theming/tokens
|
||||
- Uses \`data-component="collapsible"\` and slots for trigger/content/arrow.
|
||||
|
||||
`
|
||||
|
||||
export default {
|
||||
title: "UI/Collapsible",
|
||||
id: "components-collapsible",
|
||||
component: mod.Collapsible,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: docs,
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
variant: {
|
||||
control: "select",
|
||||
options: ["normal", "ghost"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const Basic = {
|
||||
args: {
|
||||
variant: "normal",
|
||||
defaultOpen: true,
|
||||
},
|
||||
render: (props) => (
|
||||
<mod.Collapsible {...props}>
|
||||
<mod.Collapsible.Trigger data-slot="collapsible-trigger">
|
||||
<div style={{ display: "flex", "align-items": "center", gap: "8px" }}>
|
||||
<span>Details</span>
|
||||
<mod.Collapsible.Arrow />
|
||||
</div>
|
||||
</mod.Collapsible.Trigger>
|
||||
<mod.Collapsible.Content data-slot="collapsible-content">
|
||||
<div style={{ color: "var(--text-weak)", "padding-top": "8px" }}>Optional details sit here.</div>
|
||||
</mod.Collapsible.Content>
|
||||
</mod.Collapsible>
|
||||
),
|
||||
}
|
||||
|
||||
export const Ghost = {
|
||||
args: {
|
||||
variant: "ghost",
|
||||
defaultOpen: false,
|
||||
},
|
||||
render: (props) => (
|
||||
<mod.Collapsible {...props}>
|
||||
<mod.Collapsible.Trigger data-slot="collapsible-trigger">
|
||||
<div style={{ display: "flex", "align-items": "center", gap: "8px" }}>
|
||||
<span>Ghost trigger</span>
|
||||
<mod.Collapsible.Arrow />
|
||||
</div>
|
||||
</mod.Collapsible.Trigger>
|
||||
<mod.Collapsible.Content data-slot="collapsible-content">
|
||||
<div style={{ color: "var(--text-weak)", "padding-top": "8px" }}>Ghost content.</div>
|
||||
</mod.Collapsible.Content>
|
||||
</mod.Collapsible>
|
||||
),
|
||||
}
|
||||
48
qimingcode/packages/ui/src/components/collapsible.tsx
Normal file
48
qimingcode/packages/ui/src/components/collapsible.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { Collapsible as Kobalte, CollapsibleRootProps } from "@kobalte/core/collapsible"
|
||||
import { ComponentProps, ParentProps, splitProps } from "solid-js"
|
||||
import { Icon } from "./icon"
|
||||
|
||||
export interface CollapsibleProps extends ParentProps<CollapsibleRootProps> {
|
||||
class?: string
|
||||
classList?: ComponentProps<"div">["classList"]
|
||||
variant?: "normal" | "ghost"
|
||||
}
|
||||
|
||||
function CollapsibleRoot(props: CollapsibleProps) {
|
||||
const [local, others] = splitProps(props, ["class", "classList", "variant"])
|
||||
return (
|
||||
<Kobalte
|
||||
data-component="collapsible"
|
||||
data-variant={local.variant || "normal"}
|
||||
classList={{
|
||||
...local.classList,
|
||||
[local.class ?? ""]: !!local.class,
|
||||
}}
|
||||
{...others}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CollapsibleTrigger(props: ComponentProps<typeof Kobalte.Trigger>) {
|
||||
return <Kobalte.Trigger data-slot="collapsible-trigger" {...props} />
|
||||
}
|
||||
|
||||
function CollapsibleContent(props: ComponentProps<typeof Kobalte.Content>) {
|
||||
return <Kobalte.Content data-slot="collapsible-content" {...props} />
|
||||
}
|
||||
|
||||
function CollapsibleArrow(props?: ComponentProps<"div">) {
|
||||
return (
|
||||
<div data-slot="collapsible-arrow" {...(props || {})}>
|
||||
<span data-slot="collapsible-arrow-icon">
|
||||
<Icon name="chevron-down" size="small" />
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const Collapsible = Object.assign(CollapsibleRoot, {
|
||||
Arrow: CollapsibleArrow,
|
||||
Trigger: CollapsibleTrigger,
|
||||
Content: CollapsibleContent,
|
||||
})
|
||||
134
qimingcode/packages/ui/src/components/context-menu.css
Normal file
134
qimingcode/packages/ui/src/components/context-menu.css
Normal file
@@ -0,0 +1,134 @@
|
||||
[data-component="context-menu-content"],
|
||||
[data-component="context-menu-sub-content"] {
|
||||
min-width: 8rem;
|
||||
overflow: hidden;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-xs-border);
|
||||
background-clip: padding-box;
|
||||
background-color: var(--surface-raised-stronger-non-alpha);
|
||||
padding: 4px;
|
||||
z-index: 100;
|
||||
transform-origin: var(--kb-menu-content-transform-origin);
|
||||
|
||||
&:focus-within,
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
animation: contextMenuContentHide var(--transition-duration) var(--transition-easing) forwards;
|
||||
|
||||
@starting-style {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
&[data-expanded] {
|
||||
pointer-events: auto;
|
||||
animation: contextMenuContentShow var(--transition-duration) var(--transition-easing) forwards;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="context-menu-content"],
|
||||
[data-component="context-menu-sub-content"] {
|
||||
[data-slot="context-menu-item"],
|
||||
[data-slot="context-menu-checkbox-item"],
|
||||
[data-slot="context-menu-radio-item"],
|
||||
[data-slot="context-menu-sub-trigger"] {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: default;
|
||||
outline: none;
|
||||
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: var(--line-height-large);
|
||||
letter-spacing: var(--letter-spacing-normal);
|
||||
color: var(--text-strong);
|
||||
|
||||
transition-property: background-color, color;
|
||||
transition-duration: var(--transition-duration);
|
||||
transition-timing-function: var(--transition-easing);
|
||||
user-select: none;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--surface-raised-base-hover);
|
||||
}
|
||||
|
||||
&[data-disabled] {
|
||||
color: var(--text-weak);
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="context-menu-sub-trigger"] {
|
||||
&[data-expanded] {
|
||||
background: var(--surface-raised-base-hover);
|
||||
outline: none;
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="context-menu-item-indicator"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
[data-slot="context-menu-item-label"] {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
[data-slot="context-menu-item-description"] {
|
||||
font-size: var(--font-size-x-small);
|
||||
color: var(--text-weak);
|
||||
}
|
||||
|
||||
[data-slot="context-menu-separator"] {
|
||||
height: 1px;
|
||||
margin: 4px -4px;
|
||||
border-top-color: var(--border-weak-base);
|
||||
}
|
||||
|
||||
[data-slot="context-menu-group-label"] {
|
||||
padding: 4px 8px;
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-x-small);
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: var(--line-height-large);
|
||||
letter-spacing: var(--letter-spacing-normal);
|
||||
color: var(--text-weak);
|
||||
}
|
||||
|
||||
[data-slot="context-menu-arrow"] {
|
||||
fill: var(--surface-raised-stronger-non-alpha);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes contextMenuContentShow {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scaleY(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scaleY(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes contextMenuContentHide {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: scaleY(1);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: scaleY(0.95);
|
||||
}
|
||||
}
|
||||
113
qimingcode/packages/ui/src/components/context-menu.stories.tsx
Normal file
113
qimingcode/packages/ui/src/components/context-menu.stories.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
// @ts-nocheck
|
||||
import * as mod from "./context-menu"
|
||||
|
||||
const docs = `### Overview
|
||||
Context menu for right-click interactions with composable items and submenus.
|
||||
|
||||
Use \`ItemLabel\` and \`ItemDescription\` for rich items.
|
||||
|
||||
### API
|
||||
- Root accepts Kobalte ContextMenu props (\`open\`, \`defaultOpen\`, \`onOpenChange\`).
|
||||
- Compose \`Trigger\`, \`Content\`, \`Item\`, \`Separator\`, and optional \`Sub\` sections.
|
||||
|
||||
### Variants and states
|
||||
- Supports grouped sections and nested submenus.
|
||||
|
||||
### Behavior
|
||||
- Opens on context menu gesture over the trigger element.
|
||||
|
||||
### Accessibility
|
||||
- TODO: confirm keyboard and focus behavior from Kobalte.
|
||||
|
||||
### Theming/tokens
|
||||
- Uses \`data-component="context-menu"\` and slot attributes for styling.
|
||||
|
||||
`
|
||||
|
||||
export default {
|
||||
title: "UI/ContextMenu",
|
||||
id: "components-context-menu",
|
||||
component: mod.ContextMenu,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: docs,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const Basic = {
|
||||
render: () => (
|
||||
<mod.ContextMenu defaultOpen>
|
||||
<mod.ContextMenu.Trigger>
|
||||
<div
|
||||
style={{
|
||||
padding: "20px",
|
||||
border: "1px dashed var(--border-weak)",
|
||||
"border-radius": "8px",
|
||||
color: "var(--text-weak)",
|
||||
}}
|
||||
>
|
||||
Right click (or open) here
|
||||
</div>
|
||||
</mod.ContextMenu.Trigger>
|
||||
<mod.ContextMenu.Portal>
|
||||
<mod.ContextMenu.Content>
|
||||
<mod.ContextMenu.Group>
|
||||
<mod.ContextMenu.GroupLabel>Actions</mod.ContextMenu.GroupLabel>
|
||||
<mod.ContextMenu.Item>
|
||||
<mod.ContextMenu.ItemLabel>Copy</mod.ContextMenu.ItemLabel>
|
||||
</mod.ContextMenu.Item>
|
||||
<mod.ContextMenu.Item>
|
||||
<mod.ContextMenu.ItemLabel>Paste</mod.ContextMenu.ItemLabel>
|
||||
</mod.ContextMenu.Item>
|
||||
</mod.ContextMenu.Group>
|
||||
<mod.ContextMenu.Separator />
|
||||
<mod.ContextMenu.Sub>
|
||||
<mod.ContextMenu.SubTrigger>More</mod.ContextMenu.SubTrigger>
|
||||
<mod.ContextMenu.SubContent>
|
||||
<mod.ContextMenu.Item>
|
||||
<mod.ContextMenu.ItemLabel>Duplicate</mod.ContextMenu.ItemLabel>
|
||||
</mod.ContextMenu.Item>
|
||||
<mod.ContextMenu.Item>
|
||||
<mod.ContextMenu.ItemLabel>Move</mod.ContextMenu.ItemLabel>
|
||||
</mod.ContextMenu.Item>
|
||||
</mod.ContextMenu.SubContent>
|
||||
</mod.ContextMenu.Sub>
|
||||
</mod.ContextMenu.Content>
|
||||
</mod.ContextMenu.Portal>
|
||||
</mod.ContextMenu>
|
||||
),
|
||||
}
|
||||
|
||||
export const CheckboxRadio = {
|
||||
render: () => (
|
||||
<mod.ContextMenu defaultOpen>
|
||||
<mod.ContextMenu.Trigger>
|
||||
<div
|
||||
style={{
|
||||
padding: "20px",
|
||||
border: "1px dashed var(--border-weak)",
|
||||
"border-radius": "8px",
|
||||
color: "var(--text-weak)",
|
||||
}}
|
||||
>
|
||||
Right click (or open) here
|
||||
</div>
|
||||
</mod.ContextMenu.Trigger>
|
||||
<mod.ContextMenu.Portal>
|
||||
<mod.ContextMenu.Content>
|
||||
<mod.ContextMenu.CheckboxItem checked>Show line numbers</mod.ContextMenu.CheckboxItem>
|
||||
<mod.ContextMenu.CheckboxItem>Wrap lines</mod.ContextMenu.CheckboxItem>
|
||||
<mod.ContextMenu.Separator />
|
||||
<mod.ContextMenu.RadioGroup value="compact">
|
||||
<mod.ContextMenu.RadioItem value="compact">Compact</mod.ContextMenu.RadioItem>
|
||||
<mod.ContextMenu.RadioItem value="comfortable">Comfortable</mod.ContextMenu.RadioItem>
|
||||
</mod.ContextMenu.RadioGroup>
|
||||
</mod.ContextMenu.Content>
|
||||
</mod.ContextMenu.Portal>
|
||||
</mod.ContextMenu>
|
||||
),
|
||||
}
|
||||
308
qimingcode/packages/ui/src/components/context-menu.tsx
Normal file
308
qimingcode/packages/ui/src/components/context-menu.tsx
Normal file
@@ -0,0 +1,308 @@
|
||||
import { ContextMenu as Kobalte } from "@kobalte/core/context-menu"
|
||||
import { splitProps } from "solid-js"
|
||||
import type { ComponentProps, ParentProps } from "solid-js"
|
||||
|
||||
export interface ContextMenuProps extends ComponentProps<typeof Kobalte> {}
|
||||
export interface ContextMenuTriggerProps extends ComponentProps<typeof Kobalte.Trigger> {}
|
||||
export interface ContextMenuIconProps extends ComponentProps<typeof Kobalte.Icon> {}
|
||||
export interface ContextMenuPortalProps extends ComponentProps<typeof Kobalte.Portal> {}
|
||||
export interface ContextMenuContentProps extends ComponentProps<typeof Kobalte.Content> {}
|
||||
export interface ContextMenuArrowProps extends ComponentProps<typeof Kobalte.Arrow> {}
|
||||
export interface ContextMenuSeparatorProps extends ComponentProps<typeof Kobalte.Separator> {}
|
||||
export interface ContextMenuGroupProps extends ComponentProps<typeof Kobalte.Group> {}
|
||||
export interface ContextMenuGroupLabelProps extends ComponentProps<typeof Kobalte.GroupLabel> {}
|
||||
export interface ContextMenuItemProps extends ComponentProps<typeof Kobalte.Item> {}
|
||||
export interface ContextMenuItemLabelProps extends ComponentProps<typeof Kobalte.ItemLabel> {}
|
||||
export interface ContextMenuItemDescriptionProps extends ComponentProps<typeof Kobalte.ItemDescription> {}
|
||||
export interface ContextMenuItemIndicatorProps extends ComponentProps<typeof Kobalte.ItemIndicator> {}
|
||||
export interface ContextMenuRadioGroupProps extends ComponentProps<typeof Kobalte.RadioGroup> {}
|
||||
export interface ContextMenuRadioItemProps extends ComponentProps<typeof Kobalte.RadioItem> {}
|
||||
export interface ContextMenuCheckboxItemProps extends ComponentProps<typeof Kobalte.CheckboxItem> {}
|
||||
export interface ContextMenuSubProps extends ComponentProps<typeof Kobalte.Sub> {}
|
||||
export interface ContextMenuSubTriggerProps extends ComponentProps<typeof Kobalte.SubTrigger> {}
|
||||
export interface ContextMenuSubContentProps extends ComponentProps<typeof Kobalte.SubContent> {}
|
||||
|
||||
function ContextMenuRoot(props: ContextMenuProps) {
|
||||
return <Kobalte {...props} data-component="context-menu" />
|
||||
}
|
||||
|
||||
function ContextMenuTrigger(props: ParentProps<ContextMenuTriggerProps>) {
|
||||
const [local, rest] = splitProps(props, ["class", "classList", "children"])
|
||||
return (
|
||||
<Kobalte.Trigger
|
||||
{...rest}
|
||||
data-slot="context-menu-trigger"
|
||||
classList={{
|
||||
...local.classList,
|
||||
[local.class ?? ""]: !!local.class,
|
||||
}}
|
||||
>
|
||||
{local.children}
|
||||
</Kobalte.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuIcon(props: ParentProps<ContextMenuIconProps>) {
|
||||
const [local, rest] = splitProps(props, ["class", "classList", "children"])
|
||||
return (
|
||||
<Kobalte.Icon
|
||||
{...rest}
|
||||
data-slot="context-menu-icon"
|
||||
classList={{
|
||||
...local.classList,
|
||||
[local.class ?? ""]: !!local.class,
|
||||
}}
|
||||
>
|
||||
{local.children}
|
||||
</Kobalte.Icon>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuPortal(props: ContextMenuPortalProps) {
|
||||
return <Kobalte.Portal {...props} />
|
||||
}
|
||||
|
||||
function ContextMenuContent(props: ParentProps<ContextMenuContentProps>) {
|
||||
const [local, rest] = splitProps(props, ["class", "classList", "children"])
|
||||
return (
|
||||
<Kobalte.Content
|
||||
{...rest}
|
||||
data-component="context-menu-content"
|
||||
classList={{
|
||||
...local.classList,
|
||||
[local.class ?? ""]: !!local.class,
|
||||
}}
|
||||
>
|
||||
{local.children}
|
||||
</Kobalte.Content>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuArrow(props: ContextMenuArrowProps) {
|
||||
const [local, rest] = splitProps(props, ["class", "classList"])
|
||||
return (
|
||||
<Kobalte.Arrow
|
||||
{...rest}
|
||||
data-slot="context-menu-arrow"
|
||||
classList={{
|
||||
...local.classList,
|
||||
[local.class ?? ""]: !!local.class,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuSeparator(props: ContextMenuSeparatorProps) {
|
||||
const [local, rest] = splitProps(props, ["class", "classList"])
|
||||
return (
|
||||
<Kobalte.Separator
|
||||
{...rest}
|
||||
data-slot="context-menu-separator"
|
||||
classList={{
|
||||
...local.classList,
|
||||
[local.class ?? ""]: !!local.class,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuGroup(props: ParentProps<ContextMenuGroupProps>) {
|
||||
const [local, rest] = splitProps(props, ["class", "classList", "children"])
|
||||
return (
|
||||
<Kobalte.Group
|
||||
{...rest}
|
||||
data-slot="context-menu-group"
|
||||
classList={{
|
||||
...local.classList,
|
||||
[local.class ?? ""]: !!local.class,
|
||||
}}
|
||||
>
|
||||
{local.children}
|
||||
</Kobalte.Group>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuGroupLabel(props: ParentProps<ContextMenuGroupLabelProps>) {
|
||||
const [local, rest] = splitProps(props, ["class", "classList", "children"])
|
||||
return (
|
||||
<Kobalte.GroupLabel
|
||||
{...rest}
|
||||
data-slot="context-menu-group-label"
|
||||
classList={{
|
||||
...local.classList,
|
||||
[local.class ?? ""]: !!local.class,
|
||||
}}
|
||||
>
|
||||
{local.children}
|
||||
</Kobalte.GroupLabel>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuItem(props: ParentProps<ContextMenuItemProps>) {
|
||||
const [local, rest] = splitProps(props, ["class", "classList", "children"])
|
||||
return (
|
||||
<Kobalte.Item
|
||||
{...rest}
|
||||
data-slot="context-menu-item"
|
||||
classList={{
|
||||
...local.classList,
|
||||
[local.class ?? ""]: !!local.class,
|
||||
}}
|
||||
>
|
||||
{local.children}
|
||||
</Kobalte.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuItemLabel(props: ParentProps<ContextMenuItemLabelProps>) {
|
||||
const [local, rest] = splitProps(props, ["class", "classList", "children"])
|
||||
return (
|
||||
<Kobalte.ItemLabel
|
||||
{...rest}
|
||||
data-slot="context-menu-item-label"
|
||||
classList={{
|
||||
...local.classList,
|
||||
[local.class ?? ""]: !!local.class,
|
||||
}}
|
||||
>
|
||||
{local.children}
|
||||
</Kobalte.ItemLabel>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuItemDescription(props: ParentProps<ContextMenuItemDescriptionProps>) {
|
||||
const [local, rest] = splitProps(props, ["class", "classList", "children"])
|
||||
return (
|
||||
<Kobalte.ItemDescription
|
||||
{...rest}
|
||||
data-slot="context-menu-item-description"
|
||||
classList={{
|
||||
...local.classList,
|
||||
[local.class ?? ""]: !!local.class,
|
||||
}}
|
||||
>
|
||||
{local.children}
|
||||
</Kobalte.ItemDescription>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuItemIndicator(props: ParentProps<ContextMenuItemIndicatorProps>) {
|
||||
const [local, rest] = splitProps(props, ["class", "classList", "children"])
|
||||
return (
|
||||
<Kobalte.ItemIndicator
|
||||
{...rest}
|
||||
data-slot="context-menu-item-indicator"
|
||||
classList={{
|
||||
...local.classList,
|
||||
[local.class ?? ""]: !!local.class,
|
||||
}}
|
||||
>
|
||||
{local.children}
|
||||
</Kobalte.ItemIndicator>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuRadioGroup(props: ParentProps<ContextMenuRadioGroupProps>) {
|
||||
const [local, rest] = splitProps(props, ["class", "classList", "children"])
|
||||
return (
|
||||
<Kobalte.RadioGroup
|
||||
{...rest}
|
||||
data-slot="context-menu-radio-group"
|
||||
classList={{
|
||||
...local.classList,
|
||||
[local.class ?? ""]: !!local.class,
|
||||
}}
|
||||
>
|
||||
{local.children}
|
||||
</Kobalte.RadioGroup>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuRadioItem(props: ParentProps<ContextMenuRadioItemProps>) {
|
||||
const [local, rest] = splitProps(props, ["class", "classList", "children"])
|
||||
return (
|
||||
<Kobalte.RadioItem
|
||||
{...rest}
|
||||
data-slot="context-menu-radio-item"
|
||||
classList={{
|
||||
...local.classList,
|
||||
[local.class ?? ""]: !!local.class,
|
||||
}}
|
||||
>
|
||||
{local.children}
|
||||
</Kobalte.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuCheckboxItem(props: ParentProps<ContextMenuCheckboxItemProps>) {
|
||||
const [local, rest] = splitProps(props, ["class", "classList", "children"])
|
||||
return (
|
||||
<Kobalte.CheckboxItem
|
||||
{...rest}
|
||||
data-slot="context-menu-checkbox-item"
|
||||
classList={{
|
||||
...local.classList,
|
||||
[local.class ?? ""]: !!local.class,
|
||||
}}
|
||||
>
|
||||
{local.children}
|
||||
</Kobalte.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuSub(props: ContextMenuSubProps) {
|
||||
return <Kobalte.Sub {...props} />
|
||||
}
|
||||
|
||||
function ContextMenuSubTrigger(props: ParentProps<ContextMenuSubTriggerProps>) {
|
||||
const [local, rest] = splitProps(props, ["class", "classList", "children"])
|
||||
return (
|
||||
<Kobalte.SubTrigger
|
||||
{...rest}
|
||||
data-slot="context-menu-sub-trigger"
|
||||
classList={{
|
||||
...local.classList,
|
||||
[local.class ?? ""]: !!local.class,
|
||||
}}
|
||||
>
|
||||
{local.children}
|
||||
</Kobalte.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuSubContent(props: ParentProps<ContextMenuSubContentProps>) {
|
||||
const [local, rest] = splitProps(props, ["class", "classList", "children"])
|
||||
return (
|
||||
<Kobalte.SubContent
|
||||
{...rest}
|
||||
data-component="context-menu-sub-content"
|
||||
classList={{
|
||||
...local.classList,
|
||||
[local.class ?? ""]: !!local.class,
|
||||
}}
|
||||
>
|
||||
{local.children}
|
||||
</Kobalte.SubContent>
|
||||
)
|
||||
}
|
||||
|
||||
export const ContextMenu = Object.assign(ContextMenuRoot, {
|
||||
Trigger: ContextMenuTrigger,
|
||||
Icon: ContextMenuIcon,
|
||||
Portal: ContextMenuPortal,
|
||||
Content: ContextMenuContent,
|
||||
Arrow: ContextMenuArrow,
|
||||
Separator: ContextMenuSeparator,
|
||||
Group: ContextMenuGroup,
|
||||
GroupLabel: ContextMenuGroupLabel,
|
||||
Item: ContextMenuItem,
|
||||
ItemLabel: ContextMenuItemLabel,
|
||||
ItemDescription: ContextMenuItemDescription,
|
||||
ItemIndicator: ContextMenuItemIndicator,
|
||||
RadioGroup: ContextMenuRadioGroup,
|
||||
RadioItem: ContextMenuRadioItem,
|
||||
CheckboxItem: ContextMenuCheckboxItem,
|
||||
Sub: ContextMenuSub,
|
||||
SubTrigger: ContextMenuSubTrigger,
|
||||
SubContent: ContextMenuSubContent,
|
||||
})
|
||||
181
qimingcode/packages/ui/src/components/dialog.css
Normal file
181
qimingcode/packages/ui/src/components/dialog.css
Normal file
@@ -0,0 +1,181 @@
|
||||
/* [data-component="dialog-trigger"] { } */
|
||||
|
||||
[data-component="dialog-overlay"] {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 50;
|
||||
background-color: hsl(from var(--background-base) h s l / 0.2);
|
||||
}
|
||||
|
||||
[data-component="dialog"] {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
|
||||
[data-slot="dialog-container"] {
|
||||
position: relative;
|
||||
z-index: 50;
|
||||
width: min(calc(100vw - 16px), 640px);
|
||||
height: min(calc(100vh - 16px), 512px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-items: start;
|
||||
overflow: visible;
|
||||
|
||||
[data-slot="dialog-content"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
align-self: stretch;
|
||||
width: 100%;
|
||||
max-height: 100%;
|
||||
min-height: 280px;
|
||||
overflow: auto;
|
||||
pointer-events: auto;
|
||||
|
||||
/* Hide scrollbar */
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* padding: 8px; */
|
||||
/* padding: 8px 8px 0 8px; */
|
||||
border-radius: var(--radius-xl);
|
||||
background: var(--surface-raised-stronger-non-alpha);
|
||||
background-clip: padding-box;
|
||||
box-shadow: var(--shadow-lg-border-base);
|
||||
|
||||
[data-slot="dialog-header"] {
|
||||
display: flex;
|
||||
padding: 16px 20px;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
align-self: stretch;
|
||||
|
||||
[data-slot="dialog-title"] {
|
||||
color: var(--text-strong);
|
||||
|
||||
/* text-16-medium */
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-large);
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: var(--line-height-x-large); /* 150% */
|
||||
letter-spacing: var(--letter-spacing-tight);
|
||||
}
|
||||
/* [data-slot="dialog-close-button"] {} */
|
||||
}
|
||||
|
||||
[data-slot="dialog-description"] {
|
||||
display: flex;
|
||||
padding: 16px;
|
||||
padding-left: 24px;
|
||||
padding-top: 0;
|
||||
margin-top: -8px;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
align-self: stretch;
|
||||
|
||||
color: var(--text-base);
|
||||
|
||||
/* text-14-regular */
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-regular);
|
||||
line-height: var(--line-height-large); /* 142.857% */
|
||||
letter-spacing: var(--letter-spacing-normal);
|
||||
}
|
||||
|
||||
[data-slot="dialog-body"] {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&[data-fit] {
|
||||
[data-slot="dialog-container"] {
|
||||
height: auto;
|
||||
|
||||
[data-slot="dialog-content"] {
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&[data-size="large"] [data-slot="dialog-container"] {
|
||||
width: min(calc(100vw - 32px), 800px);
|
||||
height: min(calc(100vh - 32px), 600px);
|
||||
}
|
||||
|
||||
&[data-size="x-large"] [data-slot="dialog-container"] {
|
||||
width: min(calc(100vw - 32px), 960px);
|
||||
height: min(calc(100vh - 32px), 600px);
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="dialog"][data-transition] [data-slot="dialog-content"] {
|
||||
animation: contentHide 100ms ease-in forwards;
|
||||
|
||||
&[data-expanded] {
|
||||
animation: contentShow 150ms ease-out;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes overlayShow {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@keyframes overlayHide {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@keyframes contentShow {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
@keyframes contentHide {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
}
|
||||
173
qimingcode/packages/ui/src/components/dialog.stories.tsx
Normal file
173
qimingcode/packages/ui/src/components/dialog.stories.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
// @ts-nocheck
|
||||
import { onMount } from "solid-js"
|
||||
import * as mod from "./dialog"
|
||||
import { Button } from "./button"
|
||||
import { useDialog } from "../context/dialog"
|
||||
|
||||
const docs = `### Overview
|
||||
Dialog content wrapper used with the DialogProvider for modal flows.
|
||||
|
||||
Provide concise title/description and keep body focused.
|
||||
|
||||
### API
|
||||
- Optional: \`title\`, \`description\`, \`action\`.
|
||||
- \`size\`: normal | large | x-large.
|
||||
- \`fit\` and \`transition\` control layout and animation.
|
||||
|
||||
### Variants and states
|
||||
- Sizes and optional header/action controls.
|
||||
|
||||
### Behavior
|
||||
- Intended to be rendered via \`useDialog().show\`.
|
||||
|
||||
### Accessibility
|
||||
- TODO: confirm focus trapping and aria attributes from Kobalte Dialog.
|
||||
|
||||
### Theming/tokens
|
||||
- Uses \`data-component="dialog"\` and slot attributes.
|
||||
|
||||
`
|
||||
|
||||
export default {
|
||||
title: "UI/Dialog",
|
||||
id: "components-dialog",
|
||||
component: mod.Dialog,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: docs,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const Basic = {
|
||||
render: () => {
|
||||
const dialog = useDialog()
|
||||
const open = () =>
|
||||
dialog.show(() => (
|
||||
<mod.Dialog title="Dialog" description="Description">
|
||||
Dialog body content.
|
||||
</mod.Dialog>
|
||||
))
|
||||
|
||||
onMount(open)
|
||||
|
||||
return (
|
||||
<Button variant="secondary" onClick={open}>
|
||||
Open dialog
|
||||
</Button>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
export const Sizes = {
|
||||
render: () => {
|
||||
const dialog = useDialog()
|
||||
return (
|
||||
<div style={{ display: "flex", gap: "12px" }}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() =>
|
||||
dialog.show(() => (
|
||||
<mod.Dialog title="Normal" description="Normal size">
|
||||
Normal dialog content.
|
||||
</mod.Dialog>
|
||||
))
|
||||
}
|
||||
>
|
||||
Normal
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() =>
|
||||
dialog.show(() => (
|
||||
<mod.Dialog size="large" title="Large" description="Large size">
|
||||
Large dialog content.
|
||||
</mod.Dialog>
|
||||
))
|
||||
}
|
||||
>
|
||||
Large
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() =>
|
||||
dialog.show(() => (
|
||||
<mod.Dialog size="x-large" title="Extra large" description="X-large size">
|
||||
X-large dialog content.
|
||||
</mod.Dialog>
|
||||
))
|
||||
}
|
||||
>
|
||||
X-Large
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
export const Transition = {
|
||||
render: () => {
|
||||
const dialog = useDialog()
|
||||
return (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() =>
|
||||
dialog.show(() => (
|
||||
<mod.Dialog title="Transition" description="Animated" transition>
|
||||
Transition enabled.
|
||||
</mod.Dialog>
|
||||
))
|
||||
}
|
||||
>
|
||||
Open transition dialog
|
||||
</Button>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
export const CustomAction = {
|
||||
render: () => {
|
||||
const dialog = useDialog()
|
||||
return (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() =>
|
||||
dialog.show(() => (
|
||||
<mod.Dialog
|
||||
title="Custom action"
|
||||
description="Dialog with a custom header action"
|
||||
action={<Button variant="ghost">Help</Button>}
|
||||
>
|
||||
Dialog body content.
|
||||
</mod.Dialog>
|
||||
))
|
||||
}
|
||||
>
|
||||
Open action dialog
|
||||
</Button>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
export const Fit = {
|
||||
render: () => {
|
||||
const dialog = useDialog()
|
||||
return (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() =>
|
||||
dialog.show(() => (
|
||||
<mod.Dialog title="Fit content" fit>
|
||||
Dialog fits its content.
|
||||
</mod.Dialog>
|
||||
))
|
||||
}
|
||||
>
|
||||
Open fit dialog
|
||||
</Button>
|
||||
)
|
||||
},
|
||||
}
|
||||
72
qimingcode/packages/ui/src/components/dialog.tsx
Normal file
72
qimingcode/packages/ui/src/components/dialog.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { Dialog as Kobalte } from "@kobalte/core/dialog"
|
||||
import { ComponentProps, JSXElement, Match, ParentProps, Show, Switch } from "solid-js"
|
||||
import { useI18n } from "../context/i18n"
|
||||
import { IconButton } from "./icon-button"
|
||||
|
||||
export interface DialogProps extends ParentProps {
|
||||
title?: JSXElement
|
||||
description?: JSXElement
|
||||
action?: JSXElement
|
||||
size?: "normal" | "large" | "x-large"
|
||||
class?: ComponentProps<"div">["class"]
|
||||
classList?: ComponentProps<"div">["classList"]
|
||||
fit?: boolean
|
||||
transition?: boolean
|
||||
}
|
||||
|
||||
export function Dialog(props: DialogProps) {
|
||||
const i18n = useI18n()
|
||||
return (
|
||||
<div
|
||||
data-component="dialog"
|
||||
data-fit={props.fit ? true : undefined}
|
||||
data-size={props.size || "normal"}
|
||||
data-transition={props.transition ? true : undefined}
|
||||
>
|
||||
<div data-slot="dialog-container">
|
||||
<Kobalte.Content
|
||||
data-slot="dialog-content"
|
||||
data-no-header={!props.title && !props.action ? "" : undefined}
|
||||
classList={{
|
||||
...props.classList,
|
||||
[props.class ?? ""]: !!props.class,
|
||||
}}
|
||||
onOpenAutoFocus={(e) => {
|
||||
const target = e.currentTarget as HTMLElement | null
|
||||
const autofocusEl = target?.querySelector("[autofocus]") as HTMLElement | null
|
||||
if (autofocusEl) {
|
||||
e.preventDefault()
|
||||
autofocusEl.focus()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Show when={props.title || props.action}>
|
||||
<div data-slot="dialog-header">
|
||||
<Show when={props.title}>
|
||||
<Kobalte.Title data-slot="dialog-title">{props.title}</Kobalte.Title>
|
||||
</Show>
|
||||
<Switch>
|
||||
<Match when={props.action}>{props.action}</Match>
|
||||
<Match when={true}>
|
||||
<Kobalte.CloseButton
|
||||
data-slot="dialog-close-button"
|
||||
as={IconButton}
|
||||
icon="close"
|
||||
variant="ghost"
|
||||
aria-label={i18n.t("ui.common.close")}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={props.description}>
|
||||
<Kobalte.Description data-slot="dialog-description" style={{ "margin-left": "-4px" }}>
|
||||
{props.description}
|
||||
</Kobalte.Description>
|
||||
</Show>
|
||||
<div data-slot="dialog-body">{props.children}</div>
|
||||
</Kobalte.Content>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
42
qimingcode/packages/ui/src/components/diff-changes.css
Normal file
42
qimingcode/packages/ui/src/components/diff-changes.css
Normal file
@@ -0,0 +1,42 @@
|
||||
[data-component="diff-changes"] {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
|
||||
[data-slot="diff-changes-additions"] {
|
||||
font-family: var(--font-family-mono);
|
||||
font-feature-settings: var(--font-family-mono--font-feature-settings);
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-regular);
|
||||
line-height: var(--line-height-large);
|
||||
letter-spacing: var(--letter-spacing-normal);
|
||||
text-align: right;
|
||||
color: var(--text-diff-add-base);
|
||||
}
|
||||
|
||||
[data-slot="diff-changes-deletions"] {
|
||||
font-family: var(--font-family-mono);
|
||||
font-feature-settings: var(--font-family-mono--font-feature-settings);
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-regular);
|
||||
line-height: var(--line-height-large);
|
||||
letter-spacing: var(--letter-spacing-normal);
|
||||
text-align: right;
|
||||
color: var(--text-diff-delete-base);
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="diff-changes"][data-variant="bars"] {
|
||||
width: 18px;
|
||||
height: 14px;
|
||||
flex-shrink: 0;
|
||||
|
||||
svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// @ts-nocheck
|
||||
import * as mod from "./diff-changes"
|
||||
import { create } from "../storybook/scaffold"
|
||||
import { changes } from "../storybook/fixtures"
|
||||
|
||||
const docs = `### Overview
|
||||
Summarize additions/deletions as text or compact bars.
|
||||
|
||||
Pair with \`Diff\`/\`DiffSSR\` to contextualize a change set.
|
||||
|
||||
### API
|
||||
- Required: \`changes\` as { additions, deletions } or an array of those objects.
|
||||
- Optional: \`variant\` ("default" | "bars").
|
||||
|
||||
### Variants and states
|
||||
- Default text summary or bar visualization.
|
||||
- Handles zero-change state (renders nothing in default variant).
|
||||
|
||||
### Behavior
|
||||
- Aggregates arrays into total additions/deletions.
|
||||
|
||||
### Accessibility
|
||||
- Ensure surrounding context conveys meaning of the counts/bars.
|
||||
|
||||
### Theming/tokens
|
||||
- Uses \`data-component="diff-changes"\` and diff color tokens.
|
||||
|
||||
`
|
||||
|
||||
const story = create({
|
||||
title: "UI/DiffChanges",
|
||||
mod,
|
||||
args: {
|
||||
changes,
|
||||
variant: "default",
|
||||
},
|
||||
})
|
||||
|
||||
export default {
|
||||
title: "UI/DiffChanges",
|
||||
id: "components-diff-changes",
|
||||
component: story.meta.component,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: docs,
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
variant: {
|
||||
control: "select",
|
||||
options: ["default", "bars"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const Default = story.Basic
|
||||
|
||||
export const Bars = {
|
||||
args: {
|
||||
variant: "bars",
|
||||
},
|
||||
}
|
||||
|
||||
export const MultipleFiles = {
|
||||
args: {
|
||||
changes: [
|
||||
{ additions: 4, deletions: 1 },
|
||||
{ additions: 8, deletions: 3 },
|
||||
{ additions: 2, deletions: 0 },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
export const Zero = {
|
||||
args: {
|
||||
changes: { additions: 0, deletions: 0 },
|
||||
},
|
||||
}
|
||||
115
qimingcode/packages/ui/src/components/diff-changes.tsx
Normal file
115
qimingcode/packages/ui/src/components/diff-changes.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import { createMemo, For, Match, Show, Switch } from "solid-js"
|
||||
|
||||
export function DiffChanges(props: {
|
||||
class?: string
|
||||
changes: { additions: number; deletions: number } | { additions: number; deletions: number }[]
|
||||
variant?: "default" | "bars"
|
||||
}) {
|
||||
const variant = () => props.variant ?? "default"
|
||||
|
||||
const additions = createMemo(() =>
|
||||
Array.isArray(props.changes)
|
||||
? props.changes.reduce((acc, diff) => acc + (diff.additions ?? 0), 0)
|
||||
: props.changes.additions,
|
||||
)
|
||||
const deletions = createMemo(() =>
|
||||
Array.isArray(props.changes)
|
||||
? props.changes.reduce((acc, diff) => acc + (diff.deletions ?? 0), 0)
|
||||
: props.changes.deletions,
|
||||
)
|
||||
const total = createMemo(() => (additions() ?? 0) + (deletions() ?? 0))
|
||||
|
||||
const blockCounts = createMemo(() => {
|
||||
const TOTAL_BLOCKS = 5
|
||||
|
||||
const adds = additions() ?? 0
|
||||
const dels = deletions() ?? 0
|
||||
|
||||
if (adds === 0 && dels === 0) {
|
||||
return { added: 0, deleted: 0, neutral: TOTAL_BLOCKS }
|
||||
}
|
||||
|
||||
const total = adds + dels
|
||||
|
||||
if (total < 5) {
|
||||
const added = adds > 0 ? 1 : 0
|
||||
const deleted = dels > 0 ? 1 : 0
|
||||
const neutral = TOTAL_BLOCKS - added - deleted
|
||||
return { added, deleted, neutral }
|
||||
}
|
||||
|
||||
const ratio = adds > dels ? adds / dels : dels / adds
|
||||
let BLOCKS_FOR_COLORS = TOTAL_BLOCKS
|
||||
|
||||
if (total < 20) {
|
||||
BLOCKS_FOR_COLORS = TOTAL_BLOCKS - 1
|
||||
} else if (ratio < 4) {
|
||||
BLOCKS_FOR_COLORS = TOTAL_BLOCKS - 1
|
||||
}
|
||||
|
||||
const percentAdded = adds / total
|
||||
const percentDeleted = dels / total
|
||||
|
||||
const added_raw = percentAdded * BLOCKS_FOR_COLORS
|
||||
const deleted_raw = percentDeleted * BLOCKS_FOR_COLORS
|
||||
|
||||
let added = adds > 0 ? Math.max(1, Math.round(added_raw)) : 0
|
||||
let deleted = dels > 0 ? Math.max(1, Math.round(deleted_raw)) : 0
|
||||
|
||||
// Cap bars based on actual change magnitude
|
||||
if (adds > 0 && adds <= 5) added = Math.min(added, 1)
|
||||
if (adds > 5 && adds <= 10) added = Math.min(added, 2)
|
||||
if (dels > 0 && dels <= 5) deleted = Math.min(deleted, 1)
|
||||
if (dels > 5 && dels <= 10) deleted = Math.min(deleted, 2)
|
||||
|
||||
let total_allocated = added + deleted
|
||||
if (total_allocated > BLOCKS_FOR_COLORS) {
|
||||
if (added_raw > deleted_raw) {
|
||||
added = BLOCKS_FOR_COLORS - deleted
|
||||
} else {
|
||||
deleted = BLOCKS_FOR_COLORS - added
|
||||
}
|
||||
total_allocated = added + deleted
|
||||
}
|
||||
|
||||
const neutral = Math.max(0, TOTAL_BLOCKS - total_allocated)
|
||||
|
||||
return { added, deleted, neutral }
|
||||
})
|
||||
|
||||
const ADD_COLOR = "var(--icon-diff-add-base)"
|
||||
const DELETE_COLOR = "var(--icon-diff-delete-base)"
|
||||
const NEUTRAL_COLOR = "var(--icon-weak-base)"
|
||||
|
||||
const visibleBlocks = createMemo(() => {
|
||||
const counts = blockCounts()
|
||||
const blocks = [
|
||||
...Array(counts.added).fill(ADD_COLOR),
|
||||
...Array(counts.deleted).fill(DELETE_COLOR),
|
||||
...Array(counts.neutral).fill(NEUTRAL_COLOR),
|
||||
]
|
||||
return blocks.slice(0, 5)
|
||||
})
|
||||
|
||||
return (
|
||||
<Show when={variant() === "default" ? total() > 0 : true}>
|
||||
<div data-component="diff-changes" data-variant={variant()} classList={{ [props.class ?? ""]: true }}>
|
||||
<Switch>
|
||||
<Match when={variant() === "bars"}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 14" fill="none">
|
||||
<g>
|
||||
<For each={visibleBlocks()}>
|
||||
{(color, i) => <rect x={i() * 4} width="2" height="14" rx="1" fill={color} />}
|
||||
</For>
|
||||
</g>
|
||||
</svg>
|
||||
</Match>
|
||||
<Match when={variant() === "default"}>
|
||||
<span data-slot="diff-changes-additions">{`+${additions()}`}</span>
|
||||
<span data-slot="diff-changes-deletions">{`-${deletions()}`}</span>
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// @ts-nocheck
|
||||
import * as mod from "./dock-prompt"
|
||||
import { create } from "../storybook/scaffold"
|
||||
|
||||
const docs = `### Overview
|
||||
Docked prompt layout for questions and permission requests.
|
||||
|
||||
Use with form controls or confirmation buttons in the footer.
|
||||
|
||||
### API
|
||||
- Required: \`kind\` (question | permission), \`header\`, \`children\`, \`footer\`.
|
||||
- Optional: \`ref\` for measuring or focus management.
|
||||
|
||||
### Variants and states
|
||||
- Question and permission layouts (data attributes).
|
||||
|
||||
### Behavior
|
||||
- Pure layout component; behavior handled by parent.
|
||||
|
||||
### Accessibility
|
||||
- Ensure header and footer content provide clear context and actions.
|
||||
|
||||
### Theming/tokens
|
||||
- Uses \`data-component="dock-prompt"\` with kind data attribute.
|
||||
|
||||
`
|
||||
|
||||
const story = create({
|
||||
title: "UI/DockPrompt",
|
||||
mod,
|
||||
args: {
|
||||
kind: "question",
|
||||
header: "Header",
|
||||
children: "Prompt content",
|
||||
footer: "Footer",
|
||||
},
|
||||
})
|
||||
|
||||
export default {
|
||||
title: "UI/DockPrompt",
|
||||
id: "components-dock-prompt",
|
||||
component: story.meta.component,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: docs,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const Basic = story.Basic
|
||||
|
||||
export const Permission = {
|
||||
args: {
|
||||
kind: "permission",
|
||||
header: "Allow access?",
|
||||
children: "This action needs permission to proceed.",
|
||||
footer: "Approve or deny",
|
||||
},
|
||||
}
|
||||
23
qimingcode/packages/ui/src/components/dock-prompt.tsx
Normal file
23
qimingcode/packages/ui/src/components/dock-prompt.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { JSX } from "solid-js"
|
||||
import { DockShell, DockTray } from "./dock-surface"
|
||||
|
||||
export function DockPrompt(props: {
|
||||
kind: "question" | "permission"
|
||||
header: JSX.Element
|
||||
children: JSX.Element
|
||||
footer: JSX.Element
|
||||
ref?: (el: HTMLDivElement) => void
|
||||
onKeyDown?: JSX.EventHandlerUnion<HTMLDivElement, KeyboardEvent>
|
||||
}) {
|
||||
const slot = (name: string) => `${props.kind}-${name}`
|
||||
|
||||
return (
|
||||
<div data-component="dock-prompt" data-kind={props.kind} ref={props.ref} onKeyDown={props.onKeyDown}>
|
||||
<DockShell data-slot={slot("body")}>
|
||||
<div data-slot={slot("header")}>{props.header}</div>
|
||||
<div data-slot={slot("content")}>{props.children}</div>
|
||||
</DockShell>
|
||||
<DockTray data-slot={slot("footer")}>{props.footer}</DockTray>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
23
qimingcode/packages/ui/src/components/dock-surface.css
Normal file
23
qimingcode/packages/ui/src/components/dock-surface.css
Normal file
@@ -0,0 +1,23 @@
|
||||
[data-dock-surface="shell"] {
|
||||
background-color: var(--surface-raised-stronger-non-alpha);
|
||||
box-shadow: var(--shadow-xs-border);
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
border-radius: 12px;
|
||||
overflow: clip;
|
||||
}
|
||||
|
||||
[data-dock-surface="tray"] {
|
||||
background-color: var(--background-base);
|
||||
border: 1px solid var(--border-weak-base);
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
border-radius: 12px;
|
||||
overflow: clip;
|
||||
}
|
||||
|
||||
[data-dock-surface="tray"][data-dock-attach="top"] {
|
||||
margin-top: -0.875rem;
|
||||
border-top-left-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
}
|
||||
54
qimingcode/packages/ui/src/components/dock-surface.tsx
Normal file
54
qimingcode/packages/ui/src/components/dock-surface.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { type ComponentProps, splitProps } from "solid-js"
|
||||
|
||||
export interface DockTrayProps extends ComponentProps<"div"> {
|
||||
attach?: "none" | "top"
|
||||
}
|
||||
|
||||
export function DockShell(props: ComponentProps<"div">) {
|
||||
const [split, rest] = splitProps(props, ["children", "class", "classList"])
|
||||
return (
|
||||
<div
|
||||
{...rest}
|
||||
data-dock-surface="shell"
|
||||
classList={{
|
||||
...split.classList,
|
||||
[split.class ?? ""]: !!split.class,
|
||||
}}
|
||||
>
|
||||
{split.children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function DockShellForm(props: ComponentProps<"form">) {
|
||||
const [split, rest] = splitProps(props, ["children", "class", "classList"])
|
||||
return (
|
||||
<form
|
||||
{...rest}
|
||||
data-dock-surface="shell"
|
||||
classList={{
|
||||
...split.classList,
|
||||
[split.class ?? ""]: !!split.class,
|
||||
}}
|
||||
>
|
||||
{split.children}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
export function DockTray(props: DockTrayProps) {
|
||||
const [split, rest] = splitProps(props, ["attach", "children", "class", "classList"])
|
||||
return (
|
||||
<div
|
||||
{...rest}
|
||||
data-dock-surface="tray"
|
||||
data-dock-attach={split.attach || "none"}
|
||||
classList={{
|
||||
...split.classList,
|
||||
[split.class ?? ""]: !!split.class,
|
||||
}}
|
||||
>
|
||||
{split.children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
135
qimingcode/packages/ui/src/components/dropdown-menu.css
Normal file
135
qimingcode/packages/ui/src/components/dropdown-menu.css
Normal file
@@ -0,0 +1,135 @@
|
||||
[data-component="dropdown-menu-content"],
|
||||
[data-component="dropdown-menu-sub-content"] {
|
||||
min-width: 8rem;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid color-mix(in oklch, var(--border-base) 50%, transparent);
|
||||
background-clip: padding-box;
|
||||
background-color: var(--surface-raised-stronger-non-alpha);
|
||||
padding: 4px;
|
||||
box-shadow: var(--shadow-md);
|
||||
z-index: 50;
|
||||
transform-origin: var(--kb-menu-content-transform-origin);
|
||||
|
||||
&:focus,
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&[data-closed] {
|
||||
animation: dropdown-menu-close 0.15s ease-out;
|
||||
}
|
||||
|
||||
&[data-expanded] {
|
||||
animation: dropdown-menu-open 0.15s ease-out;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="dropdown-menu-content"],
|
||||
[data-component="dropdown-menu-sub-content"] {
|
||||
[data-slot="dropdown-menu-item"],
|
||||
[data-slot="dropdown-menu-checkbox-item"],
|
||||
[data-slot="dropdown-menu-radio-item"],
|
||||
[data-slot="dropdown-menu-sub-trigger"] {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
outline: none;
|
||||
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-small);
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: var(--line-height-large);
|
||||
letter-spacing: var(--letter-spacing-normal);
|
||||
color: var(--text-strong);
|
||||
|
||||
&[data-highlighted] {
|
||||
background: var(--surface-raised-base-hover);
|
||||
}
|
||||
|
||||
&[data-disabled] {
|
||||
color: var(--text-weak);
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="dropdown-menu-checkbox-item"],
|
||||
[data-slot="dropdown-menu-radio-item"] {
|
||||
padding-right: 28px;
|
||||
}
|
||||
|
||||
[data-slot="dropdown-menu-sub-trigger"] {
|
||||
&[data-expanded] {
|
||||
background: var(--surface-raised-base-hover);
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="dropdown-menu-item-indicator"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
[data-slot="dropdown-menu-item-label"] {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
[data-slot="dropdown-menu-item-description"] {
|
||||
font-size: var(--font-size-x-small);
|
||||
color: var(--text-weak);
|
||||
}
|
||||
|
||||
[data-slot="dropdown-menu-separator"] {
|
||||
height: 1px;
|
||||
margin: 4px -4px;
|
||||
border-top-color: var(--border-weak-base);
|
||||
}
|
||||
|
||||
[data-slot="dropdown-menu-group-label"] {
|
||||
display: block;
|
||||
padding: 4px 8px;
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-x-small);
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: var(--line-height-large);
|
||||
letter-spacing: var(--letter-spacing-normal);
|
||||
color: var(--text-weak);
|
||||
}
|
||||
|
||||
[data-slot="dropdown-menu-arrow"] {
|
||||
fill: var(--surface-raised-stronger-non-alpha);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dropdown-menu-open {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.96);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dropdown-menu-close {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: scale(0.96);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// @ts-nocheck
|
||||
import * as mod from "./dropdown-menu"
|
||||
import { Button } from "./button"
|
||||
|
||||
const docs = `### Overview
|
||||
Dropdown menu built on Kobalte with composable items, groups, and submenus.
|
||||
|
||||
Use \`DropdownMenu.ItemLabel\`/\`ItemDescription\` for richer rows.
|
||||
|
||||
### API
|
||||
- Root accepts Kobalte DropdownMenu props (\`open\`, \`defaultOpen\`, \`onOpenChange\`).
|
||||
- Compose with \`Trigger\`, \`Content\`, \`Item\`, \`Separator\`, and optional \`Sub\` sections.
|
||||
|
||||
### Variants and states
|
||||
- Supports item groups, separators, and nested submenus.
|
||||
|
||||
### Behavior
|
||||
- Menu opens from trigger and renders in a portal by default.
|
||||
|
||||
### Accessibility
|
||||
- TODO: confirm keyboard navigation from Kobalte.
|
||||
|
||||
### Theming/tokens
|
||||
- Uses \`data-component="dropdown-menu"\` and slot attributes for styling.
|
||||
|
||||
`
|
||||
|
||||
export default {
|
||||
title: "UI/DropdownMenu",
|
||||
id: "components-dropdown-menu",
|
||||
component: mod.DropdownMenu,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: docs,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const Basic = {
|
||||
render: () => (
|
||||
<mod.DropdownMenu defaultOpen>
|
||||
<mod.DropdownMenu.Trigger as={Button} variant="secondary" size="small">
|
||||
Open menu
|
||||
</mod.DropdownMenu.Trigger>
|
||||
<mod.DropdownMenu.Portal>
|
||||
<mod.DropdownMenu.Content>
|
||||
<mod.DropdownMenu.Group>
|
||||
<mod.DropdownMenu.GroupLabel>Actions</mod.DropdownMenu.GroupLabel>
|
||||
<mod.DropdownMenu.Item>
|
||||
<mod.DropdownMenu.ItemLabel>New file</mod.DropdownMenu.ItemLabel>
|
||||
</mod.DropdownMenu.Item>
|
||||
<mod.DropdownMenu.Item>
|
||||
<mod.DropdownMenu.ItemLabel>Rename</mod.DropdownMenu.ItemLabel>
|
||||
<mod.DropdownMenu.ItemDescription>Shift+R</mod.DropdownMenu.ItemDescription>
|
||||
</mod.DropdownMenu.Item>
|
||||
</mod.DropdownMenu.Group>
|
||||
<mod.DropdownMenu.Separator />
|
||||
<mod.DropdownMenu.Sub>
|
||||
<mod.DropdownMenu.SubTrigger>More options</mod.DropdownMenu.SubTrigger>
|
||||
<mod.DropdownMenu.SubContent>
|
||||
<mod.DropdownMenu.Item>
|
||||
<mod.DropdownMenu.ItemLabel>Duplicate</mod.DropdownMenu.ItemLabel>
|
||||
</mod.DropdownMenu.Item>
|
||||
<mod.DropdownMenu.Item>
|
||||
<mod.DropdownMenu.ItemLabel>Move</mod.DropdownMenu.ItemLabel>
|
||||
</mod.DropdownMenu.Item>
|
||||
</mod.DropdownMenu.SubContent>
|
||||
</mod.DropdownMenu.Sub>
|
||||
</mod.DropdownMenu.Content>
|
||||
</mod.DropdownMenu.Portal>
|
||||
</mod.DropdownMenu>
|
||||
),
|
||||
}
|
||||
|
||||
export const CheckboxRadio = {
|
||||
render: () => (
|
||||
<mod.DropdownMenu defaultOpen>
|
||||
<mod.DropdownMenu.Trigger as={Button} variant="secondary" size="small">
|
||||
Open menu
|
||||
</mod.DropdownMenu.Trigger>
|
||||
<mod.DropdownMenu.Portal>
|
||||
<mod.DropdownMenu.Content>
|
||||
<mod.DropdownMenu.CheckboxItem checked>Show line numbers</mod.DropdownMenu.CheckboxItem>
|
||||
<mod.DropdownMenu.CheckboxItem>Wrap lines</mod.DropdownMenu.CheckboxItem>
|
||||
<mod.DropdownMenu.Separator />
|
||||
<mod.DropdownMenu.RadioGroup value="compact">
|
||||
<mod.DropdownMenu.RadioItem value="compact">Compact</mod.DropdownMenu.RadioItem>
|
||||
<mod.DropdownMenu.RadioItem value="comfortable">Comfortable</mod.DropdownMenu.RadioItem>
|
||||
</mod.DropdownMenu.RadioGroup>
|
||||
</mod.DropdownMenu.Content>
|
||||
</mod.DropdownMenu.Portal>
|
||||
</mod.DropdownMenu>
|
||||
),
|
||||
}
|
||||
308
qimingcode/packages/ui/src/components/dropdown-menu.tsx
Normal file
308
qimingcode/packages/ui/src/components/dropdown-menu.tsx
Normal file
@@ -0,0 +1,308 @@
|
||||
import { DropdownMenu as Kobalte } from "@kobalte/core/dropdown-menu"
|
||||
import { splitProps } from "solid-js"
|
||||
import type { ComponentProps, ParentProps } from "solid-js"
|
||||
|
||||
export interface DropdownMenuProps extends ComponentProps<typeof Kobalte> {}
|
||||
export interface DropdownMenuTriggerProps extends ComponentProps<typeof Kobalte.Trigger> {}
|
||||
export interface DropdownMenuIconProps extends ComponentProps<typeof Kobalte.Icon> {}
|
||||
export interface DropdownMenuPortalProps extends ComponentProps<typeof Kobalte.Portal> {}
|
||||
export interface DropdownMenuContentProps extends ComponentProps<typeof Kobalte.Content> {}
|
||||
export interface DropdownMenuArrowProps extends ComponentProps<typeof Kobalte.Arrow> {}
|
||||
export interface DropdownMenuSeparatorProps extends ComponentProps<typeof Kobalte.Separator> {}
|
||||
export interface DropdownMenuGroupProps extends ComponentProps<typeof Kobalte.Group> {}
|
||||
export interface DropdownMenuGroupLabelProps extends ComponentProps<typeof Kobalte.GroupLabel> {}
|
||||
export interface DropdownMenuItemProps extends ComponentProps<typeof Kobalte.Item> {}
|
||||
export interface DropdownMenuItemLabelProps extends ComponentProps<typeof Kobalte.ItemLabel> {}
|
||||
export interface DropdownMenuItemDescriptionProps extends ComponentProps<typeof Kobalte.ItemDescription> {}
|
||||
export interface DropdownMenuItemIndicatorProps extends ComponentProps<typeof Kobalte.ItemIndicator> {}
|
||||
export interface DropdownMenuRadioGroupProps extends ComponentProps<typeof Kobalte.RadioGroup> {}
|
||||
export interface DropdownMenuRadioItemProps extends ComponentProps<typeof Kobalte.RadioItem> {}
|
||||
export interface DropdownMenuCheckboxItemProps extends ComponentProps<typeof Kobalte.CheckboxItem> {}
|
||||
export interface DropdownMenuSubProps extends ComponentProps<typeof Kobalte.Sub> {}
|
||||
export interface DropdownMenuSubTriggerProps extends ComponentProps<typeof Kobalte.SubTrigger> {}
|
||||
export interface DropdownMenuSubContentProps extends ComponentProps<typeof Kobalte.SubContent> {}
|
||||
|
||||
function DropdownMenuRoot(props: DropdownMenuProps) {
|
||||
return <Kobalte {...props} data-component="dropdown-menu" />
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger(props: ParentProps<DropdownMenuTriggerProps>) {
|
||||
const [local, rest] = splitProps(props, ["class", "classList", "children"])
|
||||
return (
|
||||
<Kobalte.Trigger
|
||||
{...rest}
|
||||
data-slot="dropdown-menu-trigger"
|
||||
classList={{
|
||||
...local.classList,
|
||||
[local.class ?? ""]: !!local.class,
|
||||
}}
|
||||
>
|
||||
{local.children}
|
||||
</Kobalte.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuIcon(props: ParentProps<DropdownMenuIconProps>) {
|
||||
const [local, rest] = splitProps(props, ["class", "classList", "children"])
|
||||
return (
|
||||
<Kobalte.Icon
|
||||
{...rest}
|
||||
data-slot="dropdown-menu-icon"
|
||||
classList={{
|
||||
...local.classList,
|
||||
[local.class ?? ""]: !!local.class,
|
||||
}}
|
||||
>
|
||||
{local.children}
|
||||
</Kobalte.Icon>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuPortal(props: DropdownMenuPortalProps) {
|
||||
return <Kobalte.Portal {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuContent(props: ParentProps<DropdownMenuContentProps>) {
|
||||
const [local, rest] = splitProps(props, ["class", "classList", "children"])
|
||||
return (
|
||||
<Kobalte.Content
|
||||
{...rest}
|
||||
data-component="dropdown-menu-content"
|
||||
classList={{
|
||||
...local.classList,
|
||||
[local.class ?? ""]: !!local.class,
|
||||
}}
|
||||
>
|
||||
{local.children}
|
||||
</Kobalte.Content>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuArrow(props: DropdownMenuArrowProps) {
|
||||
const [local, rest] = splitProps(props, ["class", "classList"])
|
||||
return (
|
||||
<Kobalte.Arrow
|
||||
{...rest}
|
||||
data-slot="dropdown-menu-arrow"
|
||||
classList={{
|
||||
...local.classList,
|
||||
[local.class ?? ""]: !!local.class,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator(props: DropdownMenuSeparatorProps) {
|
||||
const [local, rest] = splitProps(props, ["class", "classList"])
|
||||
return (
|
||||
<Kobalte.Separator
|
||||
{...rest}
|
||||
data-slot="dropdown-menu-separator"
|
||||
classList={{
|
||||
...local.classList,
|
||||
[local.class ?? ""]: !!local.class,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuGroup(props: ParentProps<DropdownMenuGroupProps>) {
|
||||
const [local, rest] = splitProps(props, ["class", "classList", "children"])
|
||||
return (
|
||||
<Kobalte.Group
|
||||
{...rest}
|
||||
data-slot="dropdown-menu-group"
|
||||
classList={{
|
||||
...local.classList,
|
||||
[local.class ?? ""]: !!local.class,
|
||||
}}
|
||||
>
|
||||
{local.children}
|
||||
</Kobalte.Group>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuGroupLabel(props: ParentProps<DropdownMenuGroupLabelProps>) {
|
||||
const [local, rest] = splitProps(props, ["class", "classList", "children"])
|
||||
return (
|
||||
<Kobalte.GroupLabel
|
||||
{...rest}
|
||||
data-slot="dropdown-menu-group-label"
|
||||
classList={{
|
||||
...local.classList,
|
||||
[local.class ?? ""]: !!local.class,
|
||||
}}
|
||||
>
|
||||
{local.children}
|
||||
</Kobalte.GroupLabel>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuItem(props: ParentProps<DropdownMenuItemProps>) {
|
||||
const [local, rest] = splitProps(props, ["class", "classList", "children"])
|
||||
return (
|
||||
<Kobalte.Item
|
||||
{...rest}
|
||||
data-slot="dropdown-menu-item"
|
||||
classList={{
|
||||
...local.classList,
|
||||
[local.class ?? ""]: !!local.class,
|
||||
}}
|
||||
>
|
||||
{local.children}
|
||||
</Kobalte.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuItemLabel(props: ParentProps<DropdownMenuItemLabelProps>) {
|
||||
const [local, rest] = splitProps(props, ["class", "classList", "children"])
|
||||
return (
|
||||
<Kobalte.ItemLabel
|
||||
{...rest}
|
||||
data-slot="dropdown-menu-item-label"
|
||||
classList={{
|
||||
...local.classList,
|
||||
[local.class ?? ""]: !!local.class,
|
||||
}}
|
||||
>
|
||||
{local.children}
|
||||
</Kobalte.ItemLabel>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuItemDescription(props: ParentProps<DropdownMenuItemDescriptionProps>) {
|
||||
const [local, rest] = splitProps(props, ["class", "classList", "children"])
|
||||
return (
|
||||
<Kobalte.ItemDescription
|
||||
{...rest}
|
||||
data-slot="dropdown-menu-item-description"
|
||||
classList={{
|
||||
...local.classList,
|
||||
[local.class ?? ""]: !!local.class,
|
||||
}}
|
||||
>
|
||||
{local.children}
|
||||
</Kobalte.ItemDescription>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuItemIndicator(props: ParentProps<DropdownMenuItemIndicatorProps>) {
|
||||
const [local, rest] = splitProps(props, ["class", "classList", "children"])
|
||||
return (
|
||||
<Kobalte.ItemIndicator
|
||||
{...rest}
|
||||
data-slot="dropdown-menu-item-indicator"
|
||||
classList={{
|
||||
...local.classList,
|
||||
[local.class ?? ""]: !!local.class,
|
||||
}}
|
||||
>
|
||||
{local.children}
|
||||
</Kobalte.ItemIndicator>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup(props: ParentProps<DropdownMenuRadioGroupProps>) {
|
||||
const [local, rest] = splitProps(props, ["class", "classList", "children"])
|
||||
return (
|
||||
<Kobalte.RadioGroup
|
||||
{...rest}
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
classList={{
|
||||
...local.classList,
|
||||
[local.class ?? ""]: !!local.class,
|
||||
}}
|
||||
>
|
||||
{local.children}
|
||||
</Kobalte.RadioGroup>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem(props: ParentProps<DropdownMenuRadioItemProps>) {
|
||||
const [local, rest] = splitProps(props, ["class", "classList", "children"])
|
||||
return (
|
||||
<Kobalte.RadioItem
|
||||
{...rest}
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
classList={{
|
||||
...local.classList,
|
||||
[local.class ?? ""]: !!local.class,
|
||||
}}
|
||||
>
|
||||
{local.children}
|
||||
</Kobalte.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem(props: ParentProps<DropdownMenuCheckboxItemProps>) {
|
||||
const [local, rest] = splitProps(props, ["class", "classList", "children"])
|
||||
return (
|
||||
<Kobalte.CheckboxItem
|
||||
{...rest}
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
classList={{
|
||||
...local.classList,
|
||||
[local.class ?? ""]: !!local.class,
|
||||
}}
|
||||
>
|
||||
{local.children}
|
||||
</Kobalte.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSub(props: DropdownMenuSubProps) {
|
||||
return <Kobalte.Sub {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger(props: ParentProps<DropdownMenuSubTriggerProps>) {
|
||||
const [local, rest] = splitProps(props, ["class", "classList", "children"])
|
||||
return (
|
||||
<Kobalte.SubTrigger
|
||||
{...rest}
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
classList={{
|
||||
...local.classList,
|
||||
[local.class ?? ""]: !!local.class,
|
||||
}}
|
||||
>
|
||||
{local.children}
|
||||
</Kobalte.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent(props: ParentProps<DropdownMenuSubContentProps>) {
|
||||
const [local, rest] = splitProps(props, ["class", "classList", "children"])
|
||||
return (
|
||||
<Kobalte.SubContent
|
||||
{...rest}
|
||||
data-component="dropdown-menu-sub-content"
|
||||
classList={{
|
||||
...local.classList,
|
||||
[local.class ?? ""]: !!local.class,
|
||||
}}
|
||||
>
|
||||
{local.children}
|
||||
</Kobalte.SubContent>
|
||||
)
|
||||
}
|
||||
|
||||
export const DropdownMenu = Object.assign(DropdownMenuRoot, {
|
||||
Trigger: DropdownMenuTrigger,
|
||||
Icon: DropdownMenuIcon,
|
||||
Portal: DropdownMenuPortal,
|
||||
Content: DropdownMenuContent,
|
||||
Arrow: DropdownMenuArrow,
|
||||
Separator: DropdownMenuSeparator,
|
||||
Group: DropdownMenuGroup,
|
||||
GroupLabel: DropdownMenuGroupLabel,
|
||||
Item: DropdownMenuItem,
|
||||
ItemLabel: DropdownMenuItemLabel,
|
||||
ItemDescription: DropdownMenuItemDescription,
|
||||
ItemIndicator: DropdownMenuItemIndicator,
|
||||
RadioGroup: DropdownMenuRadioGroup,
|
||||
RadioItem: DropdownMenuRadioItem,
|
||||
CheckboxItem: DropdownMenuCheckboxItem,
|
||||
Sub: DropdownMenuSub,
|
||||
SubTrigger: DropdownMenuSubTrigger,
|
||||
SubContent: DropdownMenuSubContent,
|
||||
})
|
||||
49
qimingcode/packages/ui/src/components/favicon.stories.tsx
Normal file
49
qimingcode/packages/ui/src/components/favicon.stories.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
// @ts-nocheck
|
||||
import * as mod from "./favicon"
|
||||
|
||||
const docs = `### Overview
|
||||
Injects favicon and app icon meta tags for the document head.
|
||||
|
||||
Render once near the app root (head management).
|
||||
|
||||
### API
|
||||
- No props.
|
||||
|
||||
### Variants and states
|
||||
- Single configuration.
|
||||
|
||||
### Behavior
|
||||
- Registers link and meta tags via Solid Meta components.
|
||||
|
||||
### Accessibility
|
||||
- Not applicable.
|
||||
|
||||
### Theming/tokens
|
||||
- Not applicable.
|
||||
|
||||
`
|
||||
|
||||
export default {
|
||||
title: "UI/Favicon",
|
||||
id: "components-favicon",
|
||||
component: mod.Favicon,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: docs,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const Basic = {
|
||||
render: () => (
|
||||
<div style={{ display: "grid", gap: "8px" }}>
|
||||
<mod.Favicon />
|
||||
<div style={{ color: "var(--text-weak)", "font-size": "12px" }}>
|
||||
Head tags are injected for favicon and app icons.
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
13
qimingcode/packages/ui/src/components/favicon.tsx
Normal file
13
qimingcode/packages/ui/src/components/favicon.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Link, Meta } from "@solidjs/meta"
|
||||
|
||||
export const Favicon = () => {
|
||||
return (
|
||||
<>
|
||||
<Link rel="icon" type="image/png" href="/favicon-96x96-v3.png" sizes="96x96" />
|
||||
<Link rel="shortcut icon" href="/favicon-v3.ico" />
|
||||
<Link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon-v3.png" />
|
||||
<Link rel="manifest" href="/site.webmanifest" />
|
||||
<Meta name="apple-mobile-web-app-title" content="OpenCode" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
26
qimingcode/packages/ui/src/components/file-icon.css
Normal file
26
qimingcode/packages/ui/src/components/file-icon.css
Normal file
@@ -0,0 +1,26 @@
|
||||
[data-component="file-icon"] {
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
/*
|
||||
File tree: show monochrome weak icons by default.
|
||||
On hover, show the original file-type colors.
|
||||
*/
|
||||
[data-component="filetree"] .filetree-icon--mono {
|
||||
color: var(--icon-base);
|
||||
}
|
||||
|
||||
[data-component="filetree"] .filetree-iconpair {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
[data-component="filetree"] .filetree-iconpair [data-component="file-icon"] {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
94
qimingcode/packages/ui/src/components/file-icon.stories.tsx
Normal file
94
qimingcode/packages/ui/src/components/file-icon.stories.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
// @ts-nocheck
|
||||
import * as mod from "./file-icon"
|
||||
import { create } from "../storybook/scaffold"
|
||||
|
||||
const docs = `### Overview
|
||||
File and folder icon renderer based on file name and extension.
|
||||
|
||||
Use in file trees and lists.
|
||||
|
||||
### API
|
||||
- Required: \`node\` with \`path\` and \`type\`.
|
||||
- Optional: \`expanded\` (for folders), \`mono\` for monochrome rendering.
|
||||
|
||||
### Variants and states
|
||||
- Folder vs file icons; expanded folder variant.
|
||||
|
||||
### Behavior
|
||||
- Maps file names and extensions to sprite icons.
|
||||
|
||||
### Accessibility
|
||||
- Provide adjacent text labels for filenames; icons are decorative.
|
||||
|
||||
### Theming/tokens
|
||||
- Uses \`data-component="file-icon"\` and sprite-based styling.
|
||||
|
||||
`
|
||||
|
||||
const story = create({
|
||||
title: "UI/FileIcon",
|
||||
mod,
|
||||
args: {
|
||||
node: { path: "package.json", type: "file" },
|
||||
mono: true,
|
||||
},
|
||||
})
|
||||
|
||||
export default {
|
||||
title: "UI/FileIcon",
|
||||
id: "components-file-icon",
|
||||
component: story.meta.component,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: docs,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const Basic = story.Basic
|
||||
|
||||
export const Folder = {
|
||||
args: {
|
||||
node: { path: "src", type: "directory" },
|
||||
expanded: true,
|
||||
mono: false,
|
||||
},
|
||||
}
|
||||
|
||||
export const Samples = {
|
||||
render: () => {
|
||||
const items = [
|
||||
{ path: "README.md", type: "file" },
|
||||
{ path: "package.json", type: "file" },
|
||||
{ path: "tsconfig.json", type: "file" },
|
||||
{ path: "index.ts", type: "file" },
|
||||
{ path: "styles.css", type: "file" },
|
||||
{ path: "logo.svg", type: "file" },
|
||||
{ path: "photo.png", type: "file" },
|
||||
{ path: "Dockerfile", type: "file" },
|
||||
{ path: ".env", type: "file" },
|
||||
{ path: "src", type: "directory" },
|
||||
{ path: "public", type: "directory" },
|
||||
] as const
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gap: "12px",
|
||||
"grid-template-columns": "repeat(auto-fill, minmax(120px, 1fr))",
|
||||
}}
|
||||
>
|
||||
{items.map((node) => (
|
||||
<div style={{ display: "flex", gap: "8px", "align-items": "center" }}>
|
||||
<mod.FileIcon node={{ path: node.path, type: node.type }} mono={false} />
|
||||
<div style={{ "font-size": "12px", color: "var(--text-weak)" }}>{node.path}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
588
qimingcode/packages/ui/src/components/file-icon.tsx
Normal file
588
qimingcode/packages/ui/src/components/file-icon.tsx
Normal file
@@ -0,0 +1,588 @@
|
||||
import type { Component, JSX } from "solid-js"
|
||||
import { createMemo, createUniqueId, splitProps, Show } from "solid-js"
|
||||
import sprite from "./file-icons/sprite.svg"
|
||||
import type { IconName } from "./file-icons/types"
|
||||
|
||||
export type FileIconProps = JSX.GSVGAttributes<SVGSVGElement> & {
|
||||
node: { path: string; type: "file" | "directory" }
|
||||
expanded?: boolean
|
||||
mono?: boolean
|
||||
}
|
||||
|
||||
export const FileIcon: Component<FileIconProps> = (props) => {
|
||||
const [local, rest] = splitProps(props, ["node", "class", "classList", "expanded", "mono"])
|
||||
const name = createMemo(() => chooseIconName(local.node.path, local.node.type, local.expanded || false))
|
||||
const id = `file-icon-mono-${createUniqueId()}`
|
||||
return (
|
||||
<svg
|
||||
data-component="file-icon"
|
||||
{...rest}
|
||||
classList={{
|
||||
...local.classList,
|
||||
[local.class ?? ""]: !!local.class,
|
||||
}}
|
||||
>
|
||||
<Show when={local.mono} fallback={<use href={`${sprite}#${name()}`} />}>
|
||||
<defs>
|
||||
<mask id={id} mask-type="alpha">
|
||||
<use href={`${sprite}#${name()}`} />
|
||||
</mask>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill="currentColor" mask={`url(#${id})`} />
|
||||
</Show>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
type IconMaps = {
|
||||
fileNames: Record<string, IconName>
|
||||
fileExtensions: Record<string, IconName>
|
||||
folderNames: Record<string, IconName>
|
||||
defaults: {
|
||||
file: IconName
|
||||
folder: IconName
|
||||
folderOpen: IconName
|
||||
}
|
||||
}
|
||||
|
||||
const ICON_MAPS: IconMaps = {
|
||||
fileNames: {
|
||||
// Documentation files
|
||||
"readme.md": "Readme",
|
||||
"changelog.md": "Changelog",
|
||||
"contributing.md": "Contributing",
|
||||
"conduct.md": "Conduct",
|
||||
license: "Certificate",
|
||||
authors: "Authors",
|
||||
credits: "Credits",
|
||||
install: "Installation",
|
||||
|
||||
// Node.js files
|
||||
"package.json": "Nodejs",
|
||||
"package-lock.json": "Nodejs",
|
||||
"yarn.lock": "Yarn",
|
||||
"pnpm-lock.yaml": "Pnpm",
|
||||
"bun.lock": "Bun",
|
||||
"bun.lockb": "Bun",
|
||||
"bunfig.toml": "Bun",
|
||||
".nvmrc": "Nodejs",
|
||||
".node-version": "Nodejs",
|
||||
|
||||
// Docker files
|
||||
dockerfile: "Docker",
|
||||
"docker-compose.yml": "Docker",
|
||||
"docker-compose.yaml": "Docker",
|
||||
".dockerignore": "Docker",
|
||||
|
||||
// Config files
|
||||
"jest.config.js": "Jest",
|
||||
"jest.config.ts": "Jest",
|
||||
"jest.config.mjs": "Jest",
|
||||
"vitest.config.js": "Vitest",
|
||||
"vitest.config.ts": "Vitest",
|
||||
"tailwind.config.js": "Tailwindcss",
|
||||
"tailwind.config.ts": "Tailwindcss",
|
||||
"turbo.json": "Turborepo",
|
||||
"tsconfig.json": "Tsconfig",
|
||||
"jsconfig.json": "Jsconfig",
|
||||
".eslintrc": "Eslint",
|
||||
".eslintrc.js": "Eslint",
|
||||
".eslintrc.json": "Eslint",
|
||||
".prettierrc": "Prettier",
|
||||
".prettierrc.js": "Prettier",
|
||||
".prettierrc.json": "Prettier",
|
||||
"vite.config.js": "Vite",
|
||||
"vite.config.ts": "Vite",
|
||||
"webpack.config.js": "Webpack",
|
||||
"rollup.config.js": "Rollup",
|
||||
"astro.config.mjs": "AstroConfig",
|
||||
"astro.config.js": "AstroConfig",
|
||||
"next.config.js": "Next",
|
||||
"next.config.mjs": "Next",
|
||||
"nuxt.config.js": "Nuxt",
|
||||
"nuxt.config.ts": "Nuxt",
|
||||
"svelte.config.js": "Svelte",
|
||||
"gatsby-config.js": "Gatsby",
|
||||
"remix.config.js": "Remix",
|
||||
"prisma.schema": "Prisma",
|
||||
".gitignore": "Git",
|
||||
".gitattributes": "Git",
|
||||
makefile: "Makefile",
|
||||
cmake: "Cmake",
|
||||
"cargo.toml": "Rust",
|
||||
"go.mod": "GoMod",
|
||||
"go.sum": "GoMod",
|
||||
"requirements.txt": "Python",
|
||||
"pyproject.toml": "Python",
|
||||
pipfile: "Python",
|
||||
"poetry.lock": "Poetry",
|
||||
gemfile: "Gemfile",
|
||||
rakefile: "Ruby",
|
||||
"composer.json": "Php",
|
||||
"build.gradle": "Gradle",
|
||||
"pom.xml": "Maven",
|
||||
"deno.json": "Deno",
|
||||
"deno.jsonc": "Deno",
|
||||
"vercel.json": "Vercel",
|
||||
"netlify.toml": "Netlify",
|
||||
".env": "Tune",
|
||||
".env.local": "Tune",
|
||||
".env.development": "Tune",
|
||||
".env.production": "Tune",
|
||||
".env.example": "Tune",
|
||||
".editorconfig": "Editorconfig",
|
||||
"robots.txt": "Robots",
|
||||
"favicon.ico": "Favicon",
|
||||
browserlist: "Browserlist",
|
||||
".babelrc": "Babel",
|
||||
"babel.config.js": "Babel",
|
||||
"gulpfile.js": "Gulp",
|
||||
"gruntfile.js": "Grunt",
|
||||
"capacitor.config.json": "Capacitor",
|
||||
"ionic.config.json": "Ionic",
|
||||
"angular.json": "Angular",
|
||||
".storybook": "Storybook",
|
||||
"storybook.config.js": "Storybook",
|
||||
"cypress.config.js": "Cypress",
|
||||
"playwright.config.js": "Playwright",
|
||||
"puppeteer.config.js": "Puppeteer",
|
||||
"wrangler.toml": "Wrangler",
|
||||
"firebase.json": "Firebase",
|
||||
supabase: "Supabase",
|
||||
terraform: "Terraform",
|
||||
kubernetes: "Kubernetes",
|
||||
".gitpod.yml": "Gitpod",
|
||||
".devcontainer": "Vscode",
|
||||
"travis.yml": "Travis",
|
||||
"appveyor.yml": "Appveyor",
|
||||
".circleci": "Circleci",
|
||||
"renovate.json": "Renovate",
|
||||
"dependabot.yml": "Dependabot",
|
||||
"lerna.json": "Lerna",
|
||||
"nx.json": "Nx",
|
||||
},
|
||||
fileExtensions: {
|
||||
// Test files
|
||||
"spec.ts": "TestTs",
|
||||
"test.ts": "TestTs",
|
||||
"spec.tsx": "TestJsx",
|
||||
"test.tsx": "TestJsx",
|
||||
"spec.js": "TestJs",
|
||||
"test.js": "TestJs",
|
||||
"spec.jsx": "TestJsx",
|
||||
"test.jsx": "TestJsx",
|
||||
|
||||
// JavaScript/TypeScript
|
||||
"js.map": "JavascriptMap",
|
||||
"d.ts": "TypescriptDef",
|
||||
ts: "Typescript",
|
||||
tsx: "React_ts",
|
||||
js: "Javascript",
|
||||
jsx: "React",
|
||||
mjs: "Javascript",
|
||||
cjs: "Javascript",
|
||||
|
||||
// Web languages
|
||||
html: "Html",
|
||||
htm: "Html",
|
||||
css: "Css",
|
||||
scss: "Sass",
|
||||
sass: "Sass",
|
||||
less: "Less",
|
||||
styl: "Stylus",
|
||||
|
||||
// Data formats
|
||||
json: "Json",
|
||||
xml: "Xml",
|
||||
yml: "Yaml",
|
||||
yaml: "Yaml",
|
||||
toml: "Toml",
|
||||
hjson: "Hjson",
|
||||
|
||||
// Documentation
|
||||
md: "Markdown",
|
||||
mdx: "Mdx",
|
||||
tex: "Tex",
|
||||
|
||||
// Programming languages
|
||||
py: "Python",
|
||||
pyx: "Python",
|
||||
pyw: "Python",
|
||||
rs: "Rust",
|
||||
go: "Go",
|
||||
java: "Java",
|
||||
kt: "Kotlin",
|
||||
scala: "Scala",
|
||||
php: "Php",
|
||||
rb: "Ruby",
|
||||
cs: "Csharp",
|
||||
vb: "Visualstudio",
|
||||
cpp: "Cpp",
|
||||
cc: "Cpp",
|
||||
cxx: "Cpp",
|
||||
c: "C",
|
||||
h: "H",
|
||||
hpp: "Hpp",
|
||||
swift: "Swift",
|
||||
m: "ObjectiveC",
|
||||
mm: "ObjectiveCpp",
|
||||
dart: "Dart",
|
||||
lua: "Lua",
|
||||
pl: "Perl",
|
||||
r: "R",
|
||||
jl: "Julia",
|
||||
hs: "Haskell",
|
||||
elm: "Elm",
|
||||
ml: "Ocaml",
|
||||
clj: "Clojure",
|
||||
cljs: "Clojure",
|
||||
erl: "Erlang",
|
||||
ex: "Elixir",
|
||||
exs: "Elixir",
|
||||
nim: "Nim",
|
||||
zig: "Zig",
|
||||
v: "Vlang",
|
||||
odin: "Odin",
|
||||
gleam: "Gleam",
|
||||
grain: "Grain",
|
||||
roc: "Rocket",
|
||||
fs: "Fsharp",
|
||||
|
||||
// Shell scripts
|
||||
sh: "Console",
|
||||
bash: "Console",
|
||||
zsh: "Console",
|
||||
fish: "Console",
|
||||
ps1: "Powershell",
|
||||
|
||||
// Config/build files
|
||||
cfg: "Settings",
|
||||
ini: "Settings",
|
||||
conf: "Settings",
|
||||
properties: "Settings",
|
||||
|
||||
// Media files
|
||||
svg: "Svg",
|
||||
png: "Image",
|
||||
jpg: "Image",
|
||||
jpeg: "Image",
|
||||
gif: "Image",
|
||||
webp: "Image",
|
||||
bmp: "Image",
|
||||
ico: "Favicon",
|
||||
mp4: "Video",
|
||||
mov: "Video",
|
||||
avi: "Video",
|
||||
webm: "Video",
|
||||
mp3: "Audio",
|
||||
wav: "Audio",
|
||||
flac: "Audio",
|
||||
|
||||
// Archive files
|
||||
zip: "Zip",
|
||||
tar: "Zip",
|
||||
gz: "Zip",
|
||||
rar: "Zip",
|
||||
"7z": "Zip",
|
||||
|
||||
// Document files
|
||||
pdf: "Pdf",
|
||||
doc: "Word",
|
||||
docx: "Word",
|
||||
ppt: "Powerpoint",
|
||||
pptx: "Powerpoint",
|
||||
xls: "Document",
|
||||
xlsx: "Document",
|
||||
|
||||
// Database files
|
||||
sql: "Database",
|
||||
db: "Database",
|
||||
sqlite: "Database",
|
||||
|
||||
// Other
|
||||
env: "Tune",
|
||||
log: "Log",
|
||||
lock: "Lock",
|
||||
key: "Key",
|
||||
pem: "Certificate",
|
||||
crt: "Certificate",
|
||||
proto: "Proto",
|
||||
graphql: "Graphql",
|
||||
gql: "Graphql",
|
||||
wasm: "Webassembly",
|
||||
dockerfile: "Docker",
|
||||
},
|
||||
folderNames: {
|
||||
// Source code
|
||||
src: "FolderSrc",
|
||||
source: "FolderSrc",
|
||||
lib: "FolderLib",
|
||||
libs: "FolderLib",
|
||||
|
||||
// Testing
|
||||
test: "FolderTest",
|
||||
tests: "FolderTest",
|
||||
testing: "FolderTest",
|
||||
spec: "FolderTest",
|
||||
specs: "FolderTest",
|
||||
__tests__: "FolderTest",
|
||||
e2e: "FolderTest",
|
||||
integration: "FolderTest",
|
||||
unit: "FolderTest",
|
||||
cypress: "FolderCypress",
|
||||
|
||||
// Dependencies
|
||||
node_modules: "FolderNode",
|
||||
vendor: "FolderPackages",
|
||||
packages: "FolderPackages",
|
||||
deps: "FolderPackages",
|
||||
|
||||
// Build/dist
|
||||
build: "FolderBuildkite",
|
||||
dist: "FolderDist",
|
||||
out: "FolderDist",
|
||||
output: "FolderDist",
|
||||
target: "FolderTarget",
|
||||
|
||||
// Configuration
|
||||
config: "FolderConfig",
|
||||
configs: "FolderConfig",
|
||||
configuration: "FolderConfig",
|
||||
settings: "FolderConfig",
|
||||
env: "FolderEnvironment",
|
||||
environments: "FolderEnvironment",
|
||||
|
||||
// Docker
|
||||
docker: "FolderDocker",
|
||||
dockerfiles: "FolderDocker",
|
||||
containers: "FolderDocker",
|
||||
|
||||
// Documentation
|
||||
docs: "FolderDocs",
|
||||
doc: "FolderDocs",
|
||||
documentation: "FolderDocs",
|
||||
readme: "FolderDocs",
|
||||
|
||||
// Public/assets
|
||||
public: "FolderPublic",
|
||||
static: "FolderPublic",
|
||||
assets: "FolderImages",
|
||||
images: "FolderImages",
|
||||
img: "FolderImages",
|
||||
icons: "FolderImages",
|
||||
media: "FolderImages",
|
||||
fonts: "FolderFont",
|
||||
styles: "FolderCss",
|
||||
stylesheets: "FolderCss",
|
||||
css: "FolderCss",
|
||||
sass: "FolderSass",
|
||||
scss: "FolderSass",
|
||||
less: "FolderLess",
|
||||
|
||||
// Scripts
|
||||
scripts: "FolderScripts",
|
||||
script: "FolderScripts",
|
||||
tools: "FolderTools",
|
||||
utils: "FolderUtils",
|
||||
utilities: "FolderUtils",
|
||||
helpers: "FolderHelper",
|
||||
|
||||
// Framework specific
|
||||
components: "FolderComponents",
|
||||
component: "FolderComponents",
|
||||
views: "FolderViews",
|
||||
view: "FolderViews",
|
||||
layouts: "FolderLayout",
|
||||
layout: "FolderLayout",
|
||||
templates: "FolderTemplate",
|
||||
template: "FolderTemplate",
|
||||
hooks: "FolderHook",
|
||||
hook: "FolderHook",
|
||||
store: "FolderStore",
|
||||
stores: "FolderStore",
|
||||
state: "FolderNgrxStore",
|
||||
reducers: "FolderReduxReducer",
|
||||
reducer: "FolderReduxReducer",
|
||||
services: "FolderApi",
|
||||
service: "FolderApi",
|
||||
api: "FolderApi",
|
||||
apis: "FolderApi",
|
||||
routes: "FolderRoutes",
|
||||
route: "FolderRoutes",
|
||||
routing: "FolderRoutes",
|
||||
middleware: "FolderMiddleware",
|
||||
middlewares: "FolderMiddleware",
|
||||
controllers: "FolderController",
|
||||
controller: "FolderController",
|
||||
models: "FolderDatabase",
|
||||
model: "FolderDatabase",
|
||||
schemas: "FolderDatabase",
|
||||
schema: "FolderDatabase",
|
||||
migrations: "FolderDatabase",
|
||||
migration: "FolderDatabase",
|
||||
seeders: "FolderSeeders",
|
||||
seeder: "FolderSeeders",
|
||||
|
||||
// TypeScript
|
||||
types: "FolderTypescript",
|
||||
typing: "FolderTypescript",
|
||||
typings: "FolderTypescript",
|
||||
"@types": "FolderTypescript",
|
||||
interfaces: "FolderInterface",
|
||||
interface: "FolderInterface",
|
||||
|
||||
// Mobile
|
||||
android: "FolderAndroid",
|
||||
ios: "FolderIos",
|
||||
mobile: "FolderMobile",
|
||||
flutter: "FolderFlutter",
|
||||
|
||||
// Infrastructure
|
||||
kubernetes: "FolderKubernetes",
|
||||
k8s: "FolderKubernetes",
|
||||
terraform: "FolderTerraform",
|
||||
aws: "FolderAws",
|
||||
azure: "FolderAzurePipelines",
|
||||
firebase: "FolderFirebase",
|
||||
supabase: "FolderSupabase",
|
||||
vercel: "FolderVercel",
|
||||
netlify: "FolderNetlify",
|
||||
|
||||
// CI/CD
|
||||
".github": "FolderGithub",
|
||||
".gitlab": "FolderGitlab",
|
||||
".circleci": "FolderCircleci",
|
||||
ci: "FolderCi",
|
||||
".ci": "FolderCi",
|
||||
workflows: "FolderGhWorkflows",
|
||||
|
||||
// Git
|
||||
".git": "FolderGit",
|
||||
|
||||
// Development tools
|
||||
".vscode": "FolderVscode",
|
||||
".idea": "FolderIntellij",
|
||||
".cursor": "FolderCursor",
|
||||
".devcontainer": "FolderContainer",
|
||||
".storybook": "FolderStorybook",
|
||||
|
||||
// Localization
|
||||
i18n: "FolderI18n",
|
||||
locales: "FolderI18n",
|
||||
locale: "FolderI18n",
|
||||
lang: "FolderI18n",
|
||||
languages: "FolderI18n",
|
||||
|
||||
// Other common patterns
|
||||
temp: "FolderTemp",
|
||||
tmp: "FolderTemp",
|
||||
logs: "FolderLog",
|
||||
log: "FolderLog",
|
||||
backup: "FolderBackup",
|
||||
backups: "FolderBackup",
|
||||
examples: "FolderExamples",
|
||||
example: "FolderExamples",
|
||||
demo: "FolderExamples",
|
||||
demos: "FolderExamples",
|
||||
samples: "FolderExamples",
|
||||
sample: "FolderExamples",
|
||||
fixtures: "FolderTest",
|
||||
mocks: "FolderMock",
|
||||
mock: "FolderMock",
|
||||
data: "FolderDatabase",
|
||||
database: "FolderDatabase",
|
||||
db: "FolderDatabase",
|
||||
sql: "FolderDatabase",
|
||||
prisma: "FolderPrisma",
|
||||
drizzle: "FolderDrizzle",
|
||||
|
||||
// Security
|
||||
security: "FolderSecure",
|
||||
auth: "FolderSecure",
|
||||
authentication: "FolderSecure",
|
||||
authorization: "FolderSecure",
|
||||
keys: "FolderKeys",
|
||||
certs: "FolderKeys",
|
||||
certificates: "FolderKeys",
|
||||
|
||||
// Content
|
||||
content: "FolderContent",
|
||||
posts: "FolderContent",
|
||||
articles: "FolderContent",
|
||||
blog: "FolderContent",
|
||||
|
||||
// Functions
|
||||
functions: "FolderFunctions",
|
||||
function: "FolderFunctions",
|
||||
lambda: "FolderFunctions",
|
||||
lambdas: "FolderFunctions",
|
||||
serverless: "FolderServerless",
|
||||
|
||||
// Jobs/tasks
|
||||
jobs: "FolderJob",
|
||||
job: "FolderJob",
|
||||
tasks: "FolderTasks",
|
||||
task: "FolderTasks",
|
||||
cron: "FolderTasks",
|
||||
queue: "FolderQueue",
|
||||
queues: "FolderQueue",
|
||||
|
||||
// Desktop platforms
|
||||
desktop: "FolderDesktop",
|
||||
windows: "FolderWindows",
|
||||
macos: "FolderMacos",
|
||||
linux: "FolderLinux",
|
||||
},
|
||||
defaults: {
|
||||
file: "Document",
|
||||
folder: "Folder",
|
||||
folderOpen: "FolderOpen",
|
||||
},
|
||||
}
|
||||
|
||||
const toOpenVariant = (icon: IconName): IconName => {
|
||||
if (!icon.startsWith("Folder")) return icon
|
||||
if (icon.endsWith("_light")) return icon.replace("_light", "Open_light") as IconName
|
||||
if (!icon.endsWith("Open")) return (icon + "Open") as IconName
|
||||
return icon
|
||||
}
|
||||
|
||||
const basenameOf = (p: string) => p.split("\\").join("/").split("/").filter(Boolean).pop() ?? ""
|
||||
|
||||
const folderNameVariants = (name: string) => {
|
||||
const n = name.toLowerCase()
|
||||
return [n, `.${n}`, `_${n}`, `__${n}__`]
|
||||
}
|
||||
|
||||
const dottedSuffixesDesc = (name: string) => {
|
||||
const n = name.toLowerCase()
|
||||
const idxs: number[] = []
|
||||
for (let i = 0; i < n.length; i++) if (n[i] === ".") idxs.push(i)
|
||||
const out = new Set<string>()
|
||||
out.add(n) // allow exact whole-name "extensions" like "dockerfile"
|
||||
for (const i of idxs) if (i + 1 < n.length) out.add(n.slice(i + 1))
|
||||
return Array.from(out).sort((a, b) => b.length - a.length) // longest first
|
||||
}
|
||||
|
||||
export function chooseIconName(path: string, type: "directory" | "file", expanded: boolean): IconName {
|
||||
const base = basenameOf(path)
|
||||
const baseLower = base.toLowerCase()
|
||||
|
||||
if (type === "directory") {
|
||||
for (const cand of folderNameVariants(baseLower)) {
|
||||
const icon = ICON_MAPS.folderNames[cand]
|
||||
if (icon) return expanded ? toOpenVariant(icon) : icon
|
||||
}
|
||||
return expanded ? ICON_MAPS.defaults.folderOpen : ICON_MAPS.defaults.folder
|
||||
}
|
||||
|
||||
const byName = ICON_MAPS.fileNames[baseLower]
|
||||
if (byName) return byName
|
||||
|
||||
for (const ext of dottedSuffixesDesc(baseLower)) {
|
||||
const icon = ICON_MAPS.fileExtensions[ext]
|
||||
if (icon) return icon
|
||||
}
|
||||
|
||||
return ICON_MAPS.defaults.file
|
||||
}
|
||||
11707
qimingcode/packages/ui/src/components/file-icons/sprite.svg
Normal file
11707
qimingcode/packages/ui/src/components/file-icons/sprite.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 922 KiB |
1095
qimingcode/packages/ui/src/components/file-icons/types.ts
Normal file
1095
qimingcode/packages/ui/src/components/file-icons/types.ts
Normal file
File diff suppressed because it is too large
Load Diff
267
qimingcode/packages/ui/src/components/file-media.tsx
Normal file
267
qimingcode/packages/ui/src/components/file-media.tsx
Normal file
@@ -0,0 +1,267 @@
|
||||
import type { FileContent } from "@opencode-ai/sdk/v2"
|
||||
import { createEffect, createMemo, createResource, Match, on, Show, Switch, type JSX } from "solid-js"
|
||||
import { useI18n } from "../context/i18n"
|
||||
import {
|
||||
dataUrlFromMediaValue,
|
||||
hasMediaValue,
|
||||
isBinaryContent,
|
||||
mediaKindFromPath,
|
||||
normalizeMimeType,
|
||||
svgTextFromValue,
|
||||
} from "../pierre/media"
|
||||
|
||||
export type FileMediaOptions = {
|
||||
mode?: "auto" | "off"
|
||||
path?: string
|
||||
current?: unknown
|
||||
before?: unknown
|
||||
after?: unknown
|
||||
deleted?: boolean
|
||||
readFile?: (path: string) => Promise<FileContent | undefined>
|
||||
onLoad?: () => void
|
||||
onError?: (ctx: { kind: "image" | "audio" | "svg" }) => void
|
||||
}
|
||||
|
||||
function mediaValue(cfg: FileMediaOptions, mode: "image" | "audio") {
|
||||
if (cfg.current !== undefined) return cfg.current
|
||||
if (mode === "image") return cfg.after ?? cfg.before
|
||||
return cfg.after ?? cfg.before
|
||||
}
|
||||
|
||||
export function FileMedia(props: { media?: FileMediaOptions; fallback: () => JSX.Element }) {
|
||||
const i18n = useI18n()
|
||||
const cfg = () => props.media
|
||||
const kind = createMemo(() => {
|
||||
const media = cfg()
|
||||
if (!media || media.mode === "off") return
|
||||
return mediaKindFromPath(media.path)
|
||||
})
|
||||
|
||||
const isBinary = createMemo(() => {
|
||||
const media = cfg()
|
||||
if (!media || media.mode === "off") return false
|
||||
if (kind()) return false
|
||||
return isBinaryContent(media.current as any)
|
||||
})
|
||||
|
||||
const onLoad = () => props.media?.onLoad?.()
|
||||
|
||||
const deleted = createMemo(() => {
|
||||
const media = cfg()
|
||||
const k = kind()
|
||||
if (!media || !k) return false
|
||||
if (media.deleted) return true
|
||||
if (k === "svg") return false
|
||||
if (media.current !== undefined) return false
|
||||
return !hasMediaValue(media.after as any) && hasMediaValue(media.before as any)
|
||||
})
|
||||
|
||||
const direct = createMemo(() => {
|
||||
const media = cfg()
|
||||
const k = kind()
|
||||
if (!media || (k !== "image" && k !== "audio")) return
|
||||
return dataUrlFromMediaValue(mediaValue(media, k), k)
|
||||
})
|
||||
|
||||
const request = createMemo(() => {
|
||||
const media = cfg()
|
||||
const k = kind()
|
||||
if (!media || (k !== "image" && k !== "audio")) return
|
||||
if (media.current !== undefined) return
|
||||
if (deleted()) return
|
||||
if (direct()) return
|
||||
if (!media.path || !media.readFile) return
|
||||
|
||||
return {
|
||||
key: `${k}:${media.path}`,
|
||||
kind: k,
|
||||
path: media.path,
|
||||
readFile: media.readFile,
|
||||
onError: media.onError,
|
||||
}
|
||||
})
|
||||
|
||||
const [loaded] = createResource(request, async (input) => {
|
||||
return input.readFile(input.path).then(
|
||||
(result) => {
|
||||
const src = dataUrlFromMediaValue(result as any, input.kind)
|
||||
if (!src) {
|
||||
input.onError?.({ kind: input.kind })
|
||||
return { key: input.key, error: true as const }
|
||||
}
|
||||
|
||||
return {
|
||||
key: input.key,
|
||||
src,
|
||||
mime: input.kind === "audio" ? normalizeMimeType(result?.mimeType) : undefined,
|
||||
}
|
||||
},
|
||||
() => {
|
||||
input.onError?.({ kind: input.kind })
|
||||
return { key: input.key, error: true as const }
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
const remote = createMemo(() => {
|
||||
const input = request()
|
||||
const value = loaded()
|
||||
if (!input || !value || value.key !== input.key) return
|
||||
return value
|
||||
})
|
||||
|
||||
const src = createMemo(() => {
|
||||
const value = remote()
|
||||
return direct() ?? (value && "src" in value ? value.src : undefined)
|
||||
})
|
||||
const status = createMemo(() => {
|
||||
if (direct()) return "ready" as const
|
||||
if (!request()) return "idle" as const
|
||||
if (loaded.loading) return "loading" as const
|
||||
if (remote()?.error) return "error" as const
|
||||
if (src()) return "ready" as const
|
||||
return "idle" as const
|
||||
})
|
||||
const audioMime = createMemo(() => {
|
||||
const value = remote()
|
||||
return value && "mime" in value ? value.mime : undefined
|
||||
})
|
||||
|
||||
const svgSource = createMemo(() => {
|
||||
const media = cfg()
|
||||
if (!media || kind() !== "svg") return
|
||||
return svgTextFromValue(media.current as any)
|
||||
})
|
||||
const svgSrc = createMemo(() => {
|
||||
const media = cfg()
|
||||
if (!media || kind() !== "svg") return
|
||||
return dataUrlFromMediaValue(media.current as any, "svg")
|
||||
})
|
||||
const svgInvalid = createMemo(() => {
|
||||
const media = cfg()
|
||||
if (!media || kind() !== "svg") return
|
||||
if (svgSource() !== undefined) return
|
||||
if (!hasMediaValue(media.current as any)) return
|
||||
return [media.path, media.current] as const
|
||||
})
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
svgInvalid,
|
||||
(value) => {
|
||||
if (!value) return
|
||||
cfg()?.onError?.({ kind: "svg" })
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
const kindLabel = (value: "image" | "audio") =>
|
||||
i18n.t(value === "image" ? "ui.fileMedia.kind.image" : "ui.fileMedia.kind.audio")
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={kind() === "image" || kind() === "audio"}>
|
||||
<Show
|
||||
when={src()}
|
||||
fallback={(() => {
|
||||
const media = cfg()
|
||||
const k = kind()
|
||||
if (!media || (k !== "image" && k !== "audio")) return props.fallback()
|
||||
const label = kindLabel(k)
|
||||
|
||||
if (deleted()) {
|
||||
return (
|
||||
<div class="flex min-h-40 items-center justify-center px-6 py-4 text-center text-text-weak">
|
||||
{i18n.t("ui.fileMedia.state.removed", { kind: label })}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (status() === "loading") {
|
||||
return (
|
||||
<div class="flex min-h-40 items-center justify-center px-6 py-4 text-center text-text-weak">
|
||||
{i18n.t("ui.fileMedia.state.loading", { kind: label })}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (status() === "error") {
|
||||
return (
|
||||
<div class="flex min-h-40 items-center justify-center px-6 py-4 text-center text-text-weak">
|
||||
{i18n.t("ui.fileMedia.state.error", { kind: label })}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div class="flex min-h-40 items-center justify-center px-6 py-4 text-center text-text-weak">
|
||||
{i18n.t("ui.fileMedia.state.unavailable", { kind: label })}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
>
|
||||
{(value) => {
|
||||
const k = kind()
|
||||
if (k !== "image" && k !== "audio") return props.fallback()
|
||||
if (k === "image") {
|
||||
return (
|
||||
<div class="flex justify-center bg-background-stronger px-6 py-4">
|
||||
<img
|
||||
src={value()}
|
||||
alt={cfg()?.path}
|
||||
class="max-h-[60vh] max-w-full rounded border border-border-weak-base bg-background-base object-contain"
|
||||
onLoad={onLoad}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="flex justify-center bg-background-stronger px-6 py-4">
|
||||
<audio class="w-full max-w-xl" controls preload="metadata" onLoadedMetadata={onLoad}>
|
||||
<source src={value()} type={audioMime()} />
|
||||
</audio>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={kind() === "svg"}>
|
||||
{(() => {
|
||||
if (svgSource() === undefined && svgSrc() == null) return props.fallback()
|
||||
|
||||
return (
|
||||
<div class="flex flex-col gap-4 px-6 py-4">
|
||||
<Show when={svgSource() !== undefined}>{props.fallback()}</Show>
|
||||
<Show when={svgSrc()}>
|
||||
{(value) => (
|
||||
<div class="flex justify-center">
|
||||
<img
|
||||
src={value()}
|
||||
alt={cfg()?.path}
|
||||
class="max-h-[60vh] max-w-full rounded border border-border-weak-base bg-background-base object-contain"
|
||||
onLoad={onLoad}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</Match>
|
||||
<Match when={isBinary()}>
|
||||
<div class="flex min-h-56 flex-col items-center justify-center gap-2 px-6 py-10 text-center">
|
||||
<div class="text-14-semibold text-text-strong">
|
||||
{cfg()?.path?.split("/").pop() ?? i18n.t("ui.fileMedia.binary.title")}
|
||||
</div>
|
||||
<div class="text-14-regular text-text-weak">
|
||||
{(() => {
|
||||
const path = cfg()?.path
|
||||
if (!path) return i18n.t("ui.fileMedia.binary.description.default")
|
||||
return i18n.t("ui.fileMedia.binary.description.path", { path })
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={true}>{props.fallback()}</Match>
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
72
qimingcode/packages/ui/src/components/file-search.tsx
Normal file
72
qimingcode/packages/ui/src/components/file-search.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { Portal } from "solid-js/web"
|
||||
import { useI18n } from "../context/i18n"
|
||||
import { Icon } from "./icon"
|
||||
|
||||
export function FileSearchBar(props: {
|
||||
pos: () => { top: number; right: number }
|
||||
query: () => string
|
||||
index: () => number
|
||||
count: () => number
|
||||
setInput: (el: HTMLInputElement) => void
|
||||
onInput: (value: string) => void
|
||||
onKeyDown: (event: KeyboardEvent) => void
|
||||
onClose: () => void
|
||||
onPrev: () => void
|
||||
onNext: () => void
|
||||
}) {
|
||||
const i18n = useI18n()
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<div
|
||||
class="fixed z-50 flex h-8 items-center gap-2 rounded-md border border-border-base bg-background-base px-3 shadow-md"
|
||||
style={{
|
||||
top: `${props.pos().top}px`,
|
||||
right: `${props.pos().right}px`,
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Icon name="magnifying-glass" size="small" class="text-text-weak shrink-0" />
|
||||
<input
|
||||
ref={props.setInput}
|
||||
placeholder={i18n.t("ui.fileSearch.placeholder")}
|
||||
value={props.query()}
|
||||
class="w-40 bg-transparent outline-none text-14-regular text-text-strong placeholder:text-text-weak"
|
||||
onInput={(e) => props.onInput(e.currentTarget.value)}
|
||||
onKeyDown={(e) => props.onKeyDown(e as KeyboardEvent)}
|
||||
/>
|
||||
<div class="shrink-0 text-12-regular text-text-weak tabular-nums text-right" style={{ width: "10ch" }}>
|
||||
{props.count() ? `${props.index() + 1}/${props.count()}` : "0/0"}
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
class="size-6 grid place-items-center rounded text-text-weak hover:bg-surface-base-hover hover:text-text-strong disabled:opacity-40 disabled:pointer-events-none"
|
||||
disabled={props.count() === 0}
|
||||
aria-label={i18n.t("ui.fileSearch.previousMatch")}
|
||||
onClick={props.onPrev}
|
||||
>
|
||||
<Icon name="chevron-down" size="small" class="rotate-180" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="size-6 grid place-items-center rounded text-text-weak hover:bg-surface-base-hover hover:text-text-strong disabled:opacity-40 disabled:pointer-events-none"
|
||||
disabled={props.count() === 0}
|
||||
aria-label={i18n.t("ui.fileSearch.nextMatch")}
|
||||
onClick={props.onNext}
|
||||
>
|
||||
<Icon name="chevron-down" size="small" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="size-6 grid place-items-center rounded text-text-weak hover:bg-surface-base-hover hover:text-text-strong"
|
||||
aria-label={i18n.t("ui.fileSearch.close")}
|
||||
onClick={props.onClose}
|
||||
>
|
||||
<Icon name="close-small" size="small" />
|
||||
</button>
|
||||
</div>
|
||||
</Portal>
|
||||
)
|
||||
}
|
||||
193
qimingcode/packages/ui/src/components/file-ssr.tsx
Normal file
193
qimingcode/packages/ui/src/components/file-ssr.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
import { DIFFS_TAG_NAME, FileDiff, VirtualizedFileDiff } from "@pierre/diffs"
|
||||
import { type PreloadFileDiffResult, type PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
|
||||
import { createEffect, onCleanup, onMount, Show, splitProps } from "solid-js"
|
||||
import { Dynamic, isServer } from "solid-js/web"
|
||||
import { useWorkerPool } from "../context/worker-pool"
|
||||
import { createDefaultOptions, styleVariables } from "../pierre"
|
||||
import { markCommentedDiffLines } from "../pierre/commented-lines"
|
||||
import { fixDiffSelection } from "../pierre/diff-selection"
|
||||
import {
|
||||
applyViewerScheme,
|
||||
clearReadyWatcher,
|
||||
createReadyWatcher,
|
||||
notifyShadowReady,
|
||||
observeViewerScheme,
|
||||
} from "../pierre/file-runtime"
|
||||
import { acquireVirtualizer, virtualMetrics } from "../pierre/virtualizer"
|
||||
import { File, type DiffFileProps, type FileProps } from "./file"
|
||||
|
||||
type DiffPreload<T> = PreloadMultiFileDiffResult<T> | PreloadFileDiffResult<T>
|
||||
|
||||
type SSRDiffFileProps<T> = DiffFileProps<T> & {
|
||||
preloadedDiff: DiffPreload<T>
|
||||
}
|
||||
|
||||
function DiffSSRViewer<T>(props: SSRDiffFileProps<T>) {
|
||||
let container!: HTMLDivElement
|
||||
let fileDiffRef!: HTMLElement
|
||||
let fileDiffInstance: FileDiff<T> | undefined
|
||||
let sharedVirtualizer: NonNullable<ReturnType<typeof acquireVirtualizer>> | undefined
|
||||
|
||||
const ready = createReadyWatcher()
|
||||
const workerPool = useWorkerPool(props.diffStyle)
|
||||
|
||||
const [local, others] = splitProps(props, [
|
||||
"mode",
|
||||
"media",
|
||||
"fileDiff",
|
||||
"before",
|
||||
"after",
|
||||
"class",
|
||||
"classList",
|
||||
"annotations",
|
||||
"selectedLines",
|
||||
"commentedLines",
|
||||
"onLineSelected",
|
||||
"onLineSelectionEnd",
|
||||
"onLineNumberSelectionEnd",
|
||||
"onRendered",
|
||||
"preloadedDiff",
|
||||
])
|
||||
|
||||
const getRoot = () => fileDiffRef?.shadowRoot ?? undefined
|
||||
|
||||
const getVirtualizer = () => {
|
||||
if (sharedVirtualizer) return sharedVirtualizer.virtualizer
|
||||
const result = acquireVirtualizer(container)
|
||||
if (!result) return
|
||||
sharedVirtualizer = result
|
||||
return result.virtualizer
|
||||
}
|
||||
|
||||
const setSelectedLines = (range: DiffFileProps<T>["selectedLines"], attempt = 0) => {
|
||||
const diff = fileDiffInstance
|
||||
if (!diff) return
|
||||
|
||||
const fixed = fixDiffSelection(getRoot(), range ?? null)
|
||||
if (fixed === undefined) {
|
||||
if (attempt >= 120) return
|
||||
requestAnimationFrame(() => setSelectedLines(range ?? null, attempt + 1))
|
||||
return
|
||||
}
|
||||
|
||||
diff.setSelectedLines(fixed)
|
||||
}
|
||||
|
||||
const notifyRendered = () => {
|
||||
notifyShadowReady({
|
||||
state: ready,
|
||||
container,
|
||||
getRoot,
|
||||
isReady: (root) => root.querySelector("[data-line]") != null,
|
||||
settleFrames: 1,
|
||||
onReady: () => {
|
||||
setSelectedLines(local.selectedLines ?? null)
|
||||
local.onRendered?.()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (isServer) return
|
||||
|
||||
onCleanup(observeViewerScheme(() => fileDiffRef))
|
||||
|
||||
const virtualizer = getVirtualizer()
|
||||
const annotations = local.annotations ?? local.preloadedDiff.annotations ?? []
|
||||
fileDiffInstance = virtualizer
|
||||
? new VirtualizedFileDiff<T>(
|
||||
{
|
||||
...createDefaultOptions(props.diffStyle),
|
||||
...others,
|
||||
...local.preloadedDiff.options,
|
||||
},
|
||||
virtualizer,
|
||||
virtualMetrics,
|
||||
workerPool,
|
||||
)
|
||||
: new FileDiff<T>(
|
||||
{
|
||||
...createDefaultOptions(props.diffStyle),
|
||||
...others,
|
||||
...local.preloadedDiff.options,
|
||||
},
|
||||
workerPool,
|
||||
)
|
||||
|
||||
applyViewerScheme(fileDiffRef)
|
||||
|
||||
// @ts-expect-error private field required for hydration
|
||||
fileDiffInstance.fileContainer = fileDiffRef
|
||||
fileDiffInstance.hydrate(
|
||||
local.fileDiff
|
||||
? {
|
||||
fileDiff: local.fileDiff,
|
||||
lineAnnotations: annotations,
|
||||
fileContainer: fileDiffRef,
|
||||
containerWrapper: container,
|
||||
prerenderedHTML: local.preloadedDiff.prerenderedHTML,
|
||||
}
|
||||
: {
|
||||
oldFile: local.before,
|
||||
newFile: local.after,
|
||||
lineAnnotations: annotations,
|
||||
fileContainer: fileDiffRef,
|
||||
containerWrapper: container,
|
||||
prerenderedHTML: local.preloadedDiff.prerenderedHTML,
|
||||
},
|
||||
)
|
||||
|
||||
notifyRendered()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const diff = fileDiffInstance
|
||||
if (!diff) return
|
||||
diff.setLineAnnotations(local.annotations ?? [])
|
||||
diff.rerender()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
setSelectedLines(local.selectedLines ?? null)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const ranges = local.commentedLines ?? []
|
||||
requestAnimationFrame(() => {
|
||||
const root = getRoot()
|
||||
if (!root) return
|
||||
markCommentedDiffLines(root, ranges)
|
||||
})
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
clearReadyWatcher(ready)
|
||||
fileDiffInstance?.cleanUp()
|
||||
sharedVirtualizer?.release()
|
||||
sharedVirtualizer = undefined
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
data-component="file"
|
||||
data-mode="diff"
|
||||
style={styleVariables}
|
||||
class={local.class}
|
||||
classList={local.classList}
|
||||
ref={container}
|
||||
>
|
||||
<Dynamic component={DIFFS_TAG_NAME} ref={fileDiffRef} id="ssr-diff">
|
||||
<Show when={isServer}>
|
||||
<template shadowrootmode="open" innerHTML={local.preloadedDiff.prerenderedHTML} />
|
||||
</Show>
|
||||
</Dynamic>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export type FileSSRProps<T = {}> = FileProps<T>
|
||||
|
||||
export function FileSSR<T>(props: FileSSRProps<T>) {
|
||||
if (props.mode !== "diff" || !props.preloadedDiff) return File(props)
|
||||
return DiffSSRViewer(props as SSRDiffFileProps<T>)
|
||||
}
|
||||
42
qimingcode/packages/ui/src/components/file.css
Normal file
42
qimingcode/packages/ui/src/components/file.css
Normal file
@@ -0,0 +1,42 @@
|
||||
[data-component="file"] {
|
||||
content-visibility: auto;
|
||||
}
|
||||
|
||||
[data-component="file"][data-mode="text"] {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
[data-component="file"][data-mode="diff"] {
|
||||
[data-slot="diff-hunk-separator-line-number"] {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
background-color: var(--surface-diff-hidden-strong);
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
[data-slot="diff-hunk-separator-line-number-icon"] {
|
||||
aspect-ratio: 1;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
color: var(--icon-strong-base);
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="diff-hunk-separator-content"] {
|
||||
position: sticky;
|
||||
background-color: var(--surface-diff-hidden-base);
|
||||
color: var(--text-base);
|
||||
width: var(--diffs-column-content-width);
|
||||
left: var(--diffs-column-number-width);
|
||||
padding-left: 8px;
|
||||
user-select: none;
|
||||
cursor: default;
|
||||
text-align: left;
|
||||
|
||||
[data-slot="diff-hunk-separator-content-span"] {
|
||||
mix-blend-mode: var(--text-mix-blend-mode);
|
||||
}
|
||||
}
|
||||
}
|
||||
1132
qimingcode/packages/ui/src/components/file.tsx
Normal file
1132
qimingcode/packages/ui/src/components/file.tsx
Normal file
File diff suppressed because it is too large
Load Diff
48
qimingcode/packages/ui/src/components/font.stories.tsx
Normal file
48
qimingcode/packages/ui/src/components/font.stories.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
// @ts-nocheck
|
||||
import * as mod from "./font"
|
||||
|
||||
const docs = `### Overview
|
||||
Uses native system font stacks for sans and mono typography.
|
||||
|
||||
Optional compatibility component. Existing roots can keep rendering it, but it does nothing.
|
||||
|
||||
### API
|
||||
- No props.
|
||||
|
||||
### Variants and states
|
||||
- No variants.
|
||||
|
||||
### Behavior
|
||||
- Compatibility wrapper only. No font assets are injected or preloaded.
|
||||
|
||||
### Accessibility
|
||||
- Not applicable.
|
||||
|
||||
### Theming/tokens
|
||||
- Theme tokens come from CSS variables, not this component.
|
||||
|
||||
`
|
||||
|
||||
export default {
|
||||
title: "UI/Font",
|
||||
id: "components-font",
|
||||
component: mod.Font,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: docs,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const Basic = {
|
||||
render: () => (
|
||||
<div style={{ display: "grid", gap: "8px" }}>
|
||||
<mod.Font />
|
||||
<div style={{ "font-family": "var(--font-family-sans)" }}>OpenCode Sans Sample</div>
|
||||
<div style={{ "font-family": "var(--font-family-mono)" }}>OpenCode Mono Sample</div>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
1
qimingcode/packages/ui/src/components/font.tsx
Normal file
1
qimingcode/packages/ui/src/components/font.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export const Font = () => null
|
||||
61
qimingcode/packages/ui/src/components/hover-card.css
Normal file
61
qimingcode/packages/ui/src/components/hover-card.css
Normal file
@@ -0,0 +1,61 @@
|
||||
[data-slot="hover-card-trigger"] {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
[data-component="hover-card-content"] {
|
||||
z-index: 50;
|
||||
min-width: 200px;
|
||||
max-width: 320px;
|
||||
max-height: calc(100vh - 1rem);
|
||||
border-radius: 8px;
|
||||
background-color: var(--surface-raised-stronger-non-alpha);
|
||||
pointer-events: auto;
|
||||
|
||||
border: 1px solid color-mix(in oklch, var(--border-base) 50%, transparent);
|
||||
background-clip: padding-box;
|
||||
box-shadow: var(--shadow-md);
|
||||
|
||||
transform-origin: var(--kb-hovercard-content-transform-origin);
|
||||
|
||||
&:focus-within {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&[data-closed] {
|
||||
animation: hover-card-close 0.15s ease-out;
|
||||
}
|
||||
|
||||
&[data-expanded] {
|
||||
animation: hover-card-open 0.15s ease-out;
|
||||
}
|
||||
|
||||
[data-slot="hover-card-body"] {
|
||||
padding: 4px;
|
||||
max-height: inherit;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes hover-card-open {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.96);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes hover-card-close {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: scale(0.96);
|
||||
}
|
||||
}
|
||||
70
qimingcode/packages/ui/src/components/hover-card.stories.tsx
Normal file
70
qimingcode/packages/ui/src/components/hover-card.stories.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
// @ts-nocheck
|
||||
import { createSignal } from "solid-js"
|
||||
import * as mod from "./hover-card"
|
||||
|
||||
const docs = `### Overview
|
||||
Hover-triggered card for lightweight previews and metadata.
|
||||
|
||||
Use for short summaries; avoid dense interactive controls.
|
||||
|
||||
### API
|
||||
- Required: \`trigger\` element.
|
||||
- Children render inside the hover card body.
|
||||
|
||||
### Variants and states
|
||||
- None; content and trigger are fully composable.
|
||||
|
||||
### Behavior
|
||||
- Opens on hover/focus over the trigger.
|
||||
|
||||
### Accessibility
|
||||
- TODO: confirm focus and hover intent behavior from Kobalte.
|
||||
|
||||
### Theming/tokens
|
||||
- Uses \`data-component="hover-card-content"\` and slots for styling.
|
||||
|
||||
`
|
||||
|
||||
export default {
|
||||
title: "UI/HoverCard",
|
||||
id: "components-hover-card",
|
||||
component: mod.HoverCard,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: docs,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const Basic = {
|
||||
render: () => (
|
||||
<mod.HoverCard trigger={<span style={{ "text-decoration": "underline", cursor: "default" }}>Hover me</span>}>
|
||||
<div style={{ display: "grid", gap: "6px" }}>
|
||||
<div style={{ "font-weight": 600 }}>Preview</div>
|
||||
<div style={{ color: "var(--text-weak)", "font-size": "12px" }}>Short supporting text.</div>
|
||||
</div>
|
||||
</mod.HoverCard>
|
||||
),
|
||||
}
|
||||
|
||||
export const InlineMount = {
|
||||
render: () => {
|
||||
const [mount, setMount] = createSignal<HTMLDivElement | undefined>(undefined)
|
||||
return (
|
||||
<div ref={setMount} style={{ padding: "16px", border: "1px dashed var(--border-weak)" }}>
|
||||
<mod.HoverCard
|
||||
mount={mount()}
|
||||
trigger={<span style={{ "text-decoration": "underline", cursor: "default" }}>Hover me</span>}
|
||||
>
|
||||
<div style={{ display: "grid", gap: "6px" }}>
|
||||
<div style={{ "font-weight": 600 }}>Mounted inside</div>
|
||||
<div style={{ color: "var(--text-weak)", "font-size": "12px" }}>Uses custom mount node.</div>
|
||||
</div>
|
||||
</mod.HoverCard>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
32
qimingcode/packages/ui/src/components/hover-card.tsx
Normal file
32
qimingcode/packages/ui/src/components/hover-card.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { HoverCard as Kobalte } from "@kobalte/core/hover-card"
|
||||
import { ComponentProps, JSXElement, ParentProps, splitProps } from "solid-js"
|
||||
|
||||
export interface HoverCardProps extends ParentProps, Omit<ComponentProps<typeof Kobalte>, "children"> {
|
||||
trigger: JSXElement
|
||||
mount?: HTMLElement
|
||||
class?: ComponentProps<"div">["class"]
|
||||
classList?: ComponentProps<"div">["classList"]
|
||||
}
|
||||
|
||||
export function HoverCard(props: HoverCardProps) {
|
||||
const [local, rest] = splitProps(props, ["trigger", "mount", "class", "classList", "children"])
|
||||
|
||||
return (
|
||||
<Kobalte gutter={4} {...rest}>
|
||||
<Kobalte.Trigger as="div" data-slot="hover-card-trigger" tabIndex={-1}>
|
||||
{local.trigger}
|
||||
</Kobalte.Trigger>
|
||||
<Kobalte.Portal mount={local.mount}>
|
||||
<Kobalte.Content
|
||||
data-component="hover-card-content"
|
||||
classList={{
|
||||
...local.classList,
|
||||
[local.class ?? ""]: !!local.class,
|
||||
}}
|
||||
>
|
||||
<div data-slot="hover-card-body">{local.children}</div>
|
||||
</Kobalte.Content>
|
||||
</Kobalte.Portal>
|
||||
</Kobalte>
|
||||
)
|
||||
}
|
||||
181
qimingcode/packages/ui/src/components/icon-button.css
Normal file
181
qimingcode/packages/ui/src/components/icon-button.css
Normal file
@@ -0,0 +1,181 @@
|
||||
[data-component="icon-button"] {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-sm);
|
||||
text-decoration: none;
|
||||
user-select: none;
|
||||
aspect-ratio: 1;
|
||||
flex-shrink: 0;
|
||||
|
||||
&[data-variant="primary"] {
|
||||
background-color: var(--icon-strong-base);
|
||||
|
||||
[data-slot="icon-svg"] {
|
||||
/* color: var(--icon-weak-base); */
|
||||
color: var(--icon-invert-base);
|
||||
|
||||
/* &:hover:not(:disabled) { */
|
||||
/* color: var(--icon-weak-hover); */
|
||||
/* } */
|
||||
/* &:active:not(:disabled) { */
|
||||
/* color: var(--icon-strong-active); */
|
||||
/* } */
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: var(--icon-strong-hover);
|
||||
}
|
||||
&:focus:not(:disabled) {
|
||||
background-color: var(--icon-strong-focus);
|
||||
}
|
||||
&:active:not(:disabled) {
|
||||
background-color: var(--icon-strong-active);
|
||||
}
|
||||
&:disabled {
|
||||
background-color: var(--icon-strong-disabled);
|
||||
|
||||
[data-slot="icon-svg"] {
|
||||
color: var(--icon-invert-base);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&[data-variant="secondary"] {
|
||||
border: transparent;
|
||||
background-color: var(--button-secondary-base);
|
||||
color: var(--text-strong);
|
||||
box-shadow: var(--shadow-xs-border-base);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: var(--button-secondary-hover);
|
||||
}
|
||||
&:focus:not(:disabled) {
|
||||
background-color: var(--button-secondary-base);
|
||||
}
|
||||
&:focus-visible:not(:active) {
|
||||
background-color: var(--button-secondary-base);
|
||||
box-shadow: var(--shadow-xs-border-focus);
|
||||
}
|
||||
&:focus-visible:active {
|
||||
box-shadow: none;
|
||||
}
|
||||
&:active:not(:disabled) {
|
||||
background-color: var(--button-secondary-base);
|
||||
}
|
||||
|
||||
[data-slot="icon-svg"] {
|
||||
color: var(--icon-strong-base);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background-color: var(--icon-strong-disabled);
|
||||
color: var(--icon-invert-base);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
&[data-variant="ghost"] {
|
||||
background-color: transparent;
|
||||
/* color: var(--icon-base); */
|
||||
|
||||
[data-slot="icon-svg"] {
|
||||
color: var(--icon-base);
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: var(--surface-base-hover);
|
||||
|
||||
/* [data-slot="icon-svg"] { */
|
||||
/* color: var(--icon-hover); */
|
||||
/* } */
|
||||
}
|
||||
&:focus-visible:not(:disabled) {
|
||||
background-color: var(--surface-base-hover);
|
||||
}
|
||||
&:active:not(:disabled) {
|
||||
background-color: var(--surface-base-active);
|
||||
/* [data-slot="icon-svg"] { */
|
||||
/* color: var(--icon-active); */
|
||||
/* } */
|
||||
}
|
||||
&:selected:not(:disabled) {
|
||||
background-color: var(--surface-base-active);
|
||||
/* [data-slot="icon-svg"] { */
|
||||
/* color: var(--icon-selected); */
|
||||
/* } */
|
||||
}
|
||||
&:disabled {
|
||||
color: var(--icon-invert-base);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
&[data-size="normal"] {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
|
||||
font-size: var(--font-size-small);
|
||||
line-height: var(--line-height-large);
|
||||
gap: calc(var(--spacing) * 0.5);
|
||||
}
|
||||
|
||||
&[data-size="small"] {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
&[data-size="large"] {
|
||||
height: 32px;
|
||||
/* padding: 0 8px 0 6px; */
|
||||
gap: 8px;
|
||||
|
||||
/* text-12-medium */
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-small);
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: var(--line-height-large); /* 166.667% */
|
||||
letter-spacing: var(--letter-spacing-normal);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
[data-component="icon-button"][data-icon="stop"] [data-slot="icon-svg"] rect {
|
||||
transform-origin: center;
|
||||
transform-box: fill-box;
|
||||
animation: stop-pulse 1.8s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes stop-pulse {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.12);
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="icon-button"].titlebar-icon {
|
||||
width: 32px;
|
||||
height: 24px;
|
||||
aspect-ratio: auto;
|
||||
}
|
||||
|
||||
[data-component="icon-button"].titlebar-icon[data-variant="ghost"][aria-expanded="true"] {
|
||||
background-color: var(--surface-raised-base-active);
|
||||
}
|
||||
|
||||
[data-component="icon-button"].titlebar-icon[data-variant="ghost"][aria-expanded="true"] [data-slot="icon-svg"] {
|
||||
color: var(--icon-strong-base);
|
||||
}
|
||||
|
||||
[data-component="icon-button"].titlebar-icon[data-variant="ghost"][aria-expanded="true"]:hover:not(:disabled) {
|
||||
background-color: var(--surface-raised-base-active);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// @ts-nocheck
|
||||
import * as mod from "./icon-button"
|
||||
import { create } from "../storybook/scaffold"
|
||||
|
||||
const docs = `### Overview
|
||||
Compact icon-only button with size and variant control.
|
||||
|
||||
Use \`Button\` for text labels and primary actions.
|
||||
|
||||
### API
|
||||
- Required: \`icon\` icon name.
|
||||
- Optional: \`size\`, \`iconSize\`, \`variant\`.
|
||||
- Inherits Kobalte Button props and native button attributes.
|
||||
|
||||
### Variants and states
|
||||
- Variants: primary, secondary, ghost.
|
||||
- Sizes: small, normal, large.
|
||||
|
||||
### Behavior
|
||||
- Icon size adapts to button size unless overridden.
|
||||
|
||||
### Accessibility
|
||||
- Provide \`aria-label\` when there is no visible text.
|
||||
|
||||
### Theming/tokens
|
||||
- Uses \`data-component="icon-button"\` and size/variant data attributes.
|
||||
|
||||
`
|
||||
|
||||
const story = create({ title: "UI/IconButton", mod, args: { icon: "check", "aria-label": "Icon" } })
|
||||
export default {
|
||||
title: "UI/IconButton",
|
||||
id: "components-icon-button",
|
||||
component: story.meta.component,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: docs,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const Basic = story.Basic
|
||||
|
||||
export const Sizes = {
|
||||
render: () => (
|
||||
<div style={{ display: "flex", gap: "12px", "align-items": "center" }}>
|
||||
<mod.IconButton icon="check" size="small" aria-label="Small" />
|
||||
<mod.IconButton icon="check" size="normal" aria-label="Normal" />
|
||||
<mod.IconButton icon="check" size="large" aria-label="Large" />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const Variants = {
|
||||
render: () => (
|
||||
<div style={{ display: "flex", gap: "12px", "align-items": "center" }}>
|
||||
<mod.IconButton icon="check" variant="primary" aria-label="Primary" />
|
||||
<mod.IconButton icon="check" variant="secondary" aria-label="Secondary" />
|
||||
<mod.IconButton icon="check" variant="ghost" aria-label="Ghost" />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const IconSizeOverride = {
|
||||
render: () => (
|
||||
<div style={{ display: "flex", gap: "12px", "align-items": "center" }}>
|
||||
<mod.IconButton icon="check" size="small" iconSize="large" aria-label="Small with large icon" />
|
||||
<mod.IconButton icon="check" size="large" iconSize="small" aria-label="Large with small icon" />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
29
qimingcode/packages/ui/src/components/icon-button.tsx
Normal file
29
qimingcode/packages/ui/src/components/icon-button.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Button as Kobalte } from "@kobalte/core/button"
|
||||
import { type ComponentProps, splitProps } from "solid-js"
|
||||
import { Icon, IconProps } from "./icon"
|
||||
|
||||
export interface IconButtonProps extends ComponentProps<typeof Kobalte> {
|
||||
icon: IconProps["name"]
|
||||
size?: "small" | "normal" | "large"
|
||||
iconSize?: IconProps["size"]
|
||||
variant?: "primary" | "secondary" | "ghost"
|
||||
}
|
||||
|
||||
export function IconButton(props: ComponentProps<"button"> & IconButtonProps) {
|
||||
const [split, rest] = splitProps(props, ["variant", "size", "iconSize", "class", "classList"])
|
||||
return (
|
||||
<Kobalte
|
||||
{...rest}
|
||||
data-component="icon-button"
|
||||
data-icon={props.icon}
|
||||
data-size={split.size || "normal"}
|
||||
data-variant={split.variant || "secondary"}
|
||||
classList={{
|
||||
...split.classList,
|
||||
[split.class ?? ""]: !!split.class,
|
||||
}}
|
||||
>
|
||||
<Icon name={props.icon} size={split.iconSize ?? (split.size === "large" ? "normal" : "small")} />
|
||||
</Kobalte>
|
||||
)
|
||||
}
|
||||
34
qimingcode/packages/ui/src/components/icon.css
Normal file
34
qimingcode/packages/ui/src/components/icon.css
Normal file
@@ -0,0 +1,34 @@
|
||||
[data-component="icon"] {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
/* resize: both; */
|
||||
aspect-ratio: 1/1;
|
||||
color: var(--icon-base);
|
||||
|
||||
&[data-size="small"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
&[data-size="normal"] {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
&[data-size="medium"] {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
&[data-size="large"] {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
[data-slot="icon-svg"] {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
170
qimingcode/packages/ui/src/components/icon.stories.tsx
Normal file
170
qimingcode/packages/ui/src/components/icon.stories.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
// @ts-nocheck
|
||||
import * as mod from "./icon"
|
||||
import { create } from "../storybook/scaffold"
|
||||
|
||||
const docs = `### Overview
|
||||
Inline icon renderer using the built-in OpenCode icon set.
|
||||
|
||||
Use with \`Button\`, \`IconButton\`, and menu items.
|
||||
|
||||
### API
|
||||
- Required: \`name\` (icon key).
|
||||
- Optional: \`size\` (small | normal | medium | large).
|
||||
- Accepts standard SVG props.
|
||||
|
||||
### Variants and states
|
||||
- Size variants only.
|
||||
|
||||
### Behavior
|
||||
- Uses an internal SVG path map.
|
||||
|
||||
### Accessibility
|
||||
- Icons are aria-hidden by default; wrap with accessible text when needed.
|
||||
|
||||
### Theming/tokens
|
||||
- Uses \`data-component="icon"\` with size data attributes.
|
||||
|
||||
`
|
||||
|
||||
const names = [
|
||||
"align-right",
|
||||
"arrow-up",
|
||||
"arrow-left",
|
||||
"arrow-right",
|
||||
"archive",
|
||||
"bubble-5",
|
||||
"prompt",
|
||||
"brain",
|
||||
"bullet-list",
|
||||
"check-small",
|
||||
"chevron-down",
|
||||
"chevron-left",
|
||||
"chevron-right",
|
||||
"chevron-grabber-vertical",
|
||||
"chevron-double-right",
|
||||
"circle-x",
|
||||
"close",
|
||||
"close-small",
|
||||
"checklist",
|
||||
"console",
|
||||
"expand",
|
||||
"collapse",
|
||||
"code",
|
||||
"code-lines",
|
||||
"circle-ban-sign",
|
||||
"edit-small-2",
|
||||
"eye",
|
||||
"enter",
|
||||
"folder",
|
||||
"file-tree",
|
||||
"file-tree-active",
|
||||
"magnifying-glass",
|
||||
"plus-small",
|
||||
"plus",
|
||||
"new-session",
|
||||
"pencil-line",
|
||||
"mcp",
|
||||
"glasses",
|
||||
"magnifying-glass-menu",
|
||||
"window-cursor",
|
||||
"task",
|
||||
"stop",
|
||||
"layout-left",
|
||||
"layout-left-partial",
|
||||
"layout-left-full",
|
||||
"layout-right",
|
||||
"layout-right-partial",
|
||||
"layout-right-full",
|
||||
"square-arrow-top-right",
|
||||
"open-file",
|
||||
"speech-bubble",
|
||||
"comment",
|
||||
"folder-add-left",
|
||||
"github",
|
||||
"discord",
|
||||
"layout-bottom",
|
||||
"layout-bottom-partial",
|
||||
"layout-bottom-full",
|
||||
"dot-grid",
|
||||
"circle-check",
|
||||
"copy",
|
||||
"check",
|
||||
"photo",
|
||||
"share",
|
||||
"download",
|
||||
"menu",
|
||||
"server",
|
||||
"branch",
|
||||
"edit",
|
||||
"help",
|
||||
"settings-gear",
|
||||
"dash",
|
||||
"cloud-upload",
|
||||
"trash",
|
||||
"sliders",
|
||||
"keyboard",
|
||||
"selector",
|
||||
"arrow-down-to-line",
|
||||
"warning",
|
||||
"link",
|
||||
"providers",
|
||||
"models",
|
||||
]
|
||||
|
||||
const story = create({ title: "UI/Icon", mod, args: { name: "check" } })
|
||||
|
||||
export default {
|
||||
title: "UI/Icon",
|
||||
id: "components-icon",
|
||||
component: story.meta.component,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: docs,
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
name: {
|
||||
control: "select",
|
||||
options: names,
|
||||
},
|
||||
size: {
|
||||
control: "select",
|
||||
options: ["small", "normal", "medium", "large"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const Basic = story.Basic
|
||||
|
||||
export const Sizes = {
|
||||
render: () => (
|
||||
<div style={{ display: "flex", gap: "12px", "align-items": "center" }}>
|
||||
<mod.Icon name="check" size="small" />
|
||||
<mod.Icon name="check" size="normal" />
|
||||
<mod.Icon name="check" size="medium" />
|
||||
<mod.Icon name="check" size="large" />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const Gallery = {
|
||||
render: () => (
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gap: "12px",
|
||||
"grid-template-columns": "repeat(auto-fill, minmax(88px, 1fr))",
|
||||
}}
|
||||
>
|
||||
{names.map((name) => (
|
||||
<div style={{ display: "grid", gap: "6px", "justify-items": "center" }}>
|
||||
<mod.Icon name={name} />
|
||||
<div style={{ "font-size": "10px", color: "var(--text-weak)", "text-align": "center" }}>{name}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}
|
||||
133
qimingcode/packages/ui/src/components/icon.tsx
Normal file
133
qimingcode/packages/ui/src/components/icon.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import { splitProps, type ComponentProps } from "solid-js"
|
||||
|
||||
const icons = {
|
||||
"align-right": `<path d="M12.292 6.04167L16.2503 9.99998L12.292 13.9583M2.91699 9.99998H15.6253M17.0837 3.75V16.25" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
"arrow-up": `<path fill-rule="evenodd" clip-rule="evenodd" d="M9.99991 2.24121L16.0921 8.33343L15.2083 9.21731L10.6249 4.63397V17.5001H9.37492V4.63398L4.7916 9.21731L3.90771 8.33343L9.99991 2.24121Z" fill="currentColor"/>`,
|
||||
"arrow-left": `<path d="M8.33464 4.58398L2.91797 10.0007L8.33464 15.4173M3.33464 10.0007H17.0846" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
"arrow-right": `<path d="M11.6654 4.58398L17.082 10.0007L11.6654 15.4173M16.6654 10.0007H2.91536" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
archive: `<path d="M16.8747 6.24935H17.3747V5.74935H16.8747V6.24935ZM16.8747 16.8743V17.3743H17.3747V16.8743H16.8747ZM3.12467 16.8743H2.62467V17.3743H3.12467V16.8743ZM3.12467 6.24935V5.74935H2.62467V6.24935H3.12467ZM2.08301 2.91602V2.41602H1.58301V2.91602H2.08301ZM17.9163 2.91602H18.4163V2.41602H17.9163V2.91602ZM17.9163 6.24935V6.74935H18.4163V6.24935H17.9163ZM2.08301 6.24935H1.58301V6.74935H2.08301V6.24935ZM8.33301 9.08268H7.83301V10.0827H8.33301V9.58268V9.08268ZM11.6663 10.0827H12.1663V9.08268H11.6663V9.58268V10.0827ZM16.8747 6.24935H16.3747V16.8743H16.8747H17.3747V6.24935H16.8747ZM16.8747 16.8743V16.3743H3.12467V16.8743V17.3743H16.8747V16.8743ZM3.12467 16.8743H3.62467V6.24935H3.12467H2.62467V16.8743H3.12467ZM3.12467 6.24935V6.74935H16.8747V6.24935V5.74935H3.12467V6.24935ZM2.08301 2.91602V3.41602H17.9163V2.91602V2.41602H2.08301V2.91602ZM17.9163 2.91602H17.4163V6.24935H17.9163H18.4163V2.91602H17.9163ZM17.9163 6.24935V5.74935H2.08301V6.24935V6.74935H17.9163V6.24935ZM2.08301 6.24935H2.58301V2.91602H2.08301H1.58301V6.24935H2.08301ZM8.33301 9.58268V10.0827H11.6663V9.58268V9.08268H8.33301V9.58268Z" fill="currentColor"/>`,
|
||||
"bubble-5": `<path d="M18.3327 9.99935C18.3327 5.57227 15.0919 2.91602 9.99935 2.91602C4.90676 2.91602 1.66602 5.57227 1.66602 9.99935C1.66602 11.1487 2.45505 13.1006 2.57637 13.3939C2.58707 13.4197 2.59766 13.4434 2.60729 13.4697C2.69121 13.6987 3.04209 14.9354 1.66602 16.7674C3.51787 17.6528 5.48453 16.1973 5.48453 16.1973C6.84518 16.9193 8.46417 17.0827 9.99935 17.0827C15.0919 17.0827 18.3327 14.4264 18.3327 9.99935Z" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
prompt: `<path d="M14.5841 12.0807H17.9193V2.91406H5.6276V6.2474M14.5859 6.2474H2.08594V15.4141H5.0026V17.4974L8.7526 15.4141H14.5859V6.2474Z" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
brain: `<path d="M13.332 8.7487C11.4911 8.7487 9.9987 7.25631 9.9987 5.41536M6.66536 11.2487C8.50631 11.2487 9.9987 12.7411 9.9987 14.582M9.9987 2.78209L9.9987 17.0658M16.004 15.0475C17.1255 14.5876 17.9154 13.4849 17.9154 12.1978C17.9154 11.3363 17.5615 10.5575 16.9913 9.9987C17.5615 9.43991 17.9154 8.66108 17.9154 7.79962C17.9154 6.21199 16.7136 4.90504 15.1702 4.73878C14.7858 3.21216 13.4039 2.08203 11.758 2.08203C11.1171 2.08203 10.5162 2.25337 9.9987 2.55275C9.48117 2.25337 8.88032 2.08203 8.23944 2.08203C6.59353 2.08203 5.21157 3.21216 4.82722 4.73878C3.28377 4.90504 2.08203 6.21199 2.08203 7.79962C2.08203 8.66108 2.43585 9.43991 3.00609 9.9987C2.43585 10.5575 2.08203 11.3363 2.08203 12.1978C2.08203 13.4849 2.87191 14.5876 3.99339 15.0475C4.46688 16.7033 5.9917 17.9154 7.79962 17.9154C8.61335 17.9154 9.36972 17.6698 9.9987 17.2488C10.6277 17.6698 11.384 17.9154 12.1978 17.9154C14.0057 17.9154 15.5305 16.7033 16.004 15.0475Z" stroke="currentColor"/>`,
|
||||
fork: `<path d="M2.91602 7.91406L2.91602 2.91406H7.91602M12.0827 2.91406H17.0827L17.0827 7.91406M9.99935 9.9974L9.99935 17.0807M9.99935 9.9974L3.33268 3.33073M9.99935 9.9974L16.666 3.33073" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
"bullet-list": `<path d="M9.58329 13.7497H17.0833M9.58329 6.24967H17.0833M6.24996 6.24967C6.24996 7.17015 5.50377 7.91634 4.58329 7.91634C3.66282 7.91634 2.91663 7.17015 2.91663 6.24967C2.91663 5.3292 3.66282 4.58301 4.58329 4.58301C5.50377 4.58301 6.24996 5.3292 6.24996 6.24967ZM6.24996 13.7497C6.24996 14.6701 5.50377 15.4163 4.58329 15.4163C3.66282 15.4163 2.91663 14.6701 2.91663 13.7497C2.91663 12.8292 3.66282 12.083 4.58329 12.083C5.50377 12.083 6.24996 12.8292 6.24996 13.7497Z" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
"check-small": `<path d="M6.5 11.4412L8.97059 13.5L13.5 6.5" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
"chevron-down": `<path d="M6.6665 8.33325L9.99984 11.6666L13.3332 8.33325" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
"chevron-left": `<path d="M12 15L7 10L12 5" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
"chevron-right": `<path d="M8 15L13 10L8 5" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
"chevron-grabber-vertical": `<path d="M6.66675 12.4998L10.0001 15.8332L13.3334 12.4998M6.66675 7.49984L10.0001 4.1665L13.3334 7.49984" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
"chevron-double-right": `<path d="M11.6654 13.3346L14.9987 10.0013L11.6654 6.66797M5.83203 13.3346L9.16536 10.0013L5.83203 6.66797" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
"circle-x": `<path fill-rule="evenodd" clip-rule="evenodd" d="M1.6665 10.0003C1.6665 5.39795 5.39746 1.66699 9.99984 1.66699C14.6022 1.66699 18.3332 5.39795 18.3332 10.0003C18.3332 14.6027 14.6022 18.3337 9.99984 18.3337C5.39746 18.3337 1.6665 14.6027 1.6665 10.0003ZM7.49984 6.91107L6.91058 7.50033L9.41058 10.0003L6.91058 12.5003L7.49984 13.0896L9.99984 10.5896L12.4998 13.0896L13.0891 12.5003L10.5891 10.0003L13.0891 7.50033L12.4998 6.91107L9.99984 9.41107L7.49984 6.91107Z" fill="currentColor"/>`,
|
||||
close: `<path d="M3.75 3.75L16.25 16.25M16.25 3.75L3.75 16.25" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
"close-small": `<path d="M6 6L14 14M14 6L6 14" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
checklist: `<path d="M9.58342 13.7498H17.0834M9.58342 6.24984H17.0834M2.91675 6.6665L4.58341 7.9165L7.08341 4.1665M2.91675 14.1665L4.58341 15.4165L7.08341 11.6665" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
console: `<path d="M3.75 5.4165L8.33333 9.99984L3.75 14.5832M10.4167 14.5832H16.25" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
terminal: `<path d="M6.5 8L8.64286 10L6.5 12M10.9286 12H13.5M2 18H18V2H2V18Z" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
"terminal-active": `<path d="M2 18H18V2H2V18Z" fill="currentColor" fill-opacity="0.1"/>
|
||||
<path d="M6.5 8L8.64286 10L6.5 12M10.9286 12H13.5M2 18H18V2H2V18Z" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
review: `<path d="M7 14.5H13M7 7.99512H10.0049M10.0049 7.99512H13M10.0049 7.99512V5M10.0049 7.99512V11M18 18V2L2 2L2 18H18Z" stroke="currentColor"/>`,
|
||||
"review-active": `<path d="M18 18V2L2 2L2 18H18Z" fill="currentColor" fill-opacity="0.1"/>
|
||||
<path d="M7 14.5H13M7 7.99512H10.0049M10.0049 7.99512H13M10.0049 7.99512V5M10.0049 7.99512V11M18 18V2L2 2L2 18H18Z" stroke="currentColor"/>`,
|
||||
expand: `<path d="M4.58301 10.4163V15.4163H9.58301M10.4163 4.58301H15.4163V9.58301" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
collapse: `<path d="M16.666 8.33398H11.666V3.33398" stroke="currentColor" stroke-linecap="square"/><path d="M8.33398 16.666V11.666H3.33398" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
code: `<path d="M8.7513 7.5013L6.2513 10.0013L8.7513 12.5013M11.2513 7.5013L13.7513 10.0013L11.2513 12.5013M2.91797 2.91797H17.0846V17.0846H2.91797V2.91797Z" stroke="currentColor"/>`,
|
||||
"code-lines": `<path d="M2.08325 3.75H11.2499M14.5833 3.75H17.9166M2.08325 10L7.08325 10M10.4166 10L17.9166 10M2.08325 16.25L8.74992 16.25M12.0833 16.25L17.9166 16.25" stroke="currentColor" stroke-linecap="square" stroke-linejoin="round"/>`,
|
||||
"circle-ban-sign": `<path d="M15.3675 4.63087L4.55742 15.441M17.9163 9.9987C17.9163 14.371 14.3719 17.9154 9.99967 17.9154C7.81355 17.9154 5.83438 17.0293 4.40175 15.5966C2.96911 14.164 2.08301 12.1848 2.08301 9.9987C2.08301 5.62644 5.62742 2.08203 9.99967 2.08203C12.1858 2.08203 14.165 2.96813 15.5976 4.40077C17.0302 5.8334 17.9163 7.81257 17.9163 9.9987Z" stroke="currentColor" stroke-linecap="round"/>`,
|
||||
"edit-small-2": `<path d="M17.0834 17.0833V17.5833H17.5834V17.0833H17.0834ZM2.91675 17.0833H2.41675V17.5833H2.91675V17.0833ZM2.91675 2.91659V2.41659H2.41675V2.91659H2.91675ZM9.58341 3.41659H10.0834V2.41659H9.58341V2.91659V3.41659ZM17.5834 10.4166V9.91659H16.5834V10.4166H17.0834H17.5834ZM10.4167 7.08325L10.0632 6.7297L9.91675 6.87615V7.08325H10.4167ZM10.4167 9.58325H9.91675V10.0833H10.4167V9.58325ZM12.9167 9.58325V10.0833H13.1239L13.2703 9.93681L12.9167 9.58325ZM15.4167 2.08325L15.7703 1.7297L15.4167 1.37615L15.0632 1.7297L15.4167 2.08325ZM17.9167 4.58325L18.2703 4.93681L18.6239 4.58325L18.2703 4.2297L17.9167 4.58325ZM17.0834 17.0833V16.5833H2.91675V17.0833V17.5833H17.0834V17.0833ZM2.91675 17.0833H3.41675V2.91659H2.91675H2.41675V17.0833H2.91675ZM2.91675 2.91659V3.41659H9.58341V2.91659V2.41659H2.91675V2.91659ZM17.0834 10.4166H16.5834V17.0833H17.0834H17.5834V10.4166H17.0834ZM10.4167 7.08325H9.91675V9.58325H10.4167H10.9167V7.08325H10.4167ZM10.4167 9.58325V10.0833H12.9167V9.58325V9.08325H10.4167V9.58325ZM10.4167 7.08325L10.7703 7.43681L15.7703 2.43681L15.4167 2.08325L15.0632 1.7297L10.0632 6.7297L10.4167 7.08325ZM15.4167 2.08325L15.0632 2.43681L17.5632 4.93681L17.9167 4.58325L18.2703 4.2297L15.7703 1.7297L15.4167 2.08325ZM17.9167 4.58325L17.5632 4.2297L12.5632 9.2297L12.9167 9.58325L13.2703 9.93681L18.2703 4.93681L17.9167 4.58325Z" fill="currentColor"/>`,
|
||||
eye: `<path d="M10 4.58325C5.83333 4.58325 2.5 9.99992 2.5 9.99992C2.5 9.99992 5.83333 15.4166 10 15.4166C14.1667 15.4166 17.5 9.99992 17.5 9.99992C17.5 9.99992 14.1667 4.58325 10 4.58325Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/><circle cx="10" cy="10" r="2.5" stroke="currentColor"/>`,
|
||||
enter: `<path d="M5.83333 15.8334L2.5 12.5L5.83333 9.16671M3.33333 12.5H17.9167V4.58337H10" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
folder: `<path d="M2.08301 2.91675V16.2501H17.9163V5.41675H9.99967L8.33301 2.91675H2.08301Z" stroke="currentColor" stroke-linecap="round"/>`,
|
||||
"file-tree": `<path d="M18 18V5H9.5L7.5 2H2L2 18H5M18 18H5M18 18V8.5H5V18" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
"file-tree-active": `<path d="M2 2L2 18H5L6.5 8.5H18V5H9.5L7.5 2H2Z" fill="currentColor" fill-opacity="0.1"/>
|
||||
<path d="M5 18H18L19.5 8.5H18M5 18H2L2 2H7.5L9.5 5H18V8.5M5 18L6.5 8.5H18" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
"magnifying-glass": `<path d="M13 13L10.6418 10.6418M11.9552 7.47761C11.9552 9.95053 9.95053 11.9552 7.47761 11.9552C5.0047 11.9552 3 9.95053 3 7.47761C3 5.0047 5.0047 3 7.47761 3C9.95053 3 11.9552 5.0047 11.9552 7.47761Z" stroke="currentColor" stroke-linecap="square" vector-effect="non-scaling-stroke"/>`,
|
||||
"plus-small": `<path d="M9.99984 5.41699V10.0003M9.99984 10.0003V14.5837M9.99984 10.0003H5.4165M9.99984 10.0003H14.5832" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
plus: `<path d="M9.9987 2.20703V9.9987M9.9987 9.9987V17.7904M9.9987 9.9987H2.20703M9.9987 9.9987H17.7904" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
"new-session": `<path d="M12 2H2V18H18V8M6 11.3818V14H8.61818L18 4.61818L15.3818 2L6 11.3818Z" stroke="currentColor"/>`,
|
||||
"new-session-active": `<path d="M6 11.3818V14H8.61818L18 4.61818L15.3818 2L6 11.3818Z" fill="currentColor" fill-opacity="0.1"/>
|
||||
<path d="M12 2H2V18H18V8M6 11.3818V14H8.61818L18 4.61818L15.3818 2L6 11.3818Z" stroke="currentColor"/>`,
|
||||
"pencil-line": `<path d="M9.58301 17.9166H17.9163M17.9163 5.83325L14.1663 2.08325L2.08301 14.1666V17.9166H5.83301L17.9163 5.83325Z" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
mcp: `<g><path d="M0.972656 9.37176L9.5214 1.60019C10.7018 0.527151 12.6155 0.527151 13.7957 1.60019C14.9761 2.67321 14.9761 4.41295 13.7957 5.48599L7.3397 11.3552" stroke="currentColor" stroke-linecap="round"/><path d="M7.42871 11.2747L13.7957 5.48643C14.9761 4.41338 16.8898 4.41338 18.0702 5.48643L18.1147 5.52688C19.2951 6.59993 19.2951 8.33966 18.1147 9.4127L10.3831 16.4414C9.98966 16.7991 9.98966 17.379 10.3831 17.7366L11.9707 19.1799" stroke="currentColor" stroke-linecap="round"/><path d="M11.6587 3.54346L5.33619 9.29119C4.15584 10.3642 4.15584 12.1039 5.33619 13.177C6.51649 14.25 8.43019 14.25 9.61054 13.177L15.9331 7.42923" stroke="currentColor" stroke-linecap="round"/></g>`,
|
||||
glasses: `<path d="M0.416626 7.91667H1.66663M19.5833 7.91667H18.3333M11.866 7.57987C11.3165 7.26398 10.6793 7.08333 9.99996 7.08333C9.32061 7.08333 8.68344 7.26398 8.13389 7.57987M8.74996 10C8.74996 12.0711 7.07103 13.75 4.99996 13.75C2.92889 13.75 1.24996 12.0711 1.24996 10C1.24996 7.92893 2.92889 6.25 4.99996 6.25C7.07103 6.25 8.74996 7.92893 8.74996 10ZM18.75 10C18.75 12.0711 17.071 13.75 15 13.75C12.9289 13.75 11.25 12.0711 11.25 10C11.25 7.92893 12.9289 6.25 15 6.25C17.071 6.25 18.75 7.92893 18.75 10Z" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
"magnifying-glass-menu": `<path d="M2.08325 10.0002H4.58325M2.08325 5.41683H5.41659M2.08325 14.5835H5.41659M16.4583 13.9585L18.7499 16.2502M17.9166 10.0002C17.9166 12.9917 15.4915 15.4168 12.4999 15.4168C9.50838 15.4168 7.08325 12.9917 7.08325 10.0002C7.08325 7.00862 9.50838 4.5835 12.4999 4.5835C15.4915 4.5835 17.9166 7.00862 17.9166 10.0002Z" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
"window-cursor": `<path d="M17.9166 10.4167V3.75H2.08325V17.0833H10.4166M17.9166 13.5897L11.6666 11.6667L13.5897 17.9167L15.032 15.0321L17.9166 13.5897Z" stroke="currentColor" stroke-width="1.07143" stroke-linecap="square"/><path d="M5.00024 6.125C5.29925 6.12518 5.54126 6.36795 5.54126 6.66699C5.54108 6.96589 5.29914 7.20783 5.00024 7.20801C4.7012 7.20801 4.45843 6.966 4.45825 6.66699C4.45825 6.36784 4.70109 6.125 5.00024 6.125ZM7.91626 6.125C8.21541 6.125 8.45825 6.36784 8.45825 6.66699C8.45808 6.966 8.21531 7.20801 7.91626 7.20801C7.61736 7.20783 7.37542 6.96589 7.37524 6.66699C7.37524 6.36795 7.61726 6.12518 7.91626 6.125ZM10.8333 6.125C11.1324 6.125 11.3752 6.36784 11.3752 6.66699C11.3751 6.966 11.1323 7.20801 10.8333 7.20801C10.5342 7.20801 10.2914 6.966 10.2913 6.66699C10.2913 6.36784 10.5341 6.125 10.8333 6.125Z" fill="currentColor" stroke="currentColor" stroke-width="0.25" stroke-linecap="square"/>`,
|
||||
task: `<path d="M9.99992 2.0835V17.9168M7.08325 3.75016H2.08325V16.2502H7.08325M12.9166 16.2502H17.9166V3.75016H12.9166" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
stop: `<rect x="5" y="5" width="10" height="10" fill="currentColor"/>`,
|
||||
status: `<path d="M2 10V18H18V10M2 10V2H18V10M2 10H18M5 6H9M5 14H9" stroke="currentColor"/>`,
|
||||
"status-active": `<path d="M18 2H2V10H18V2Z" fill="currentColor" fill-opacity="0.1"/>
|
||||
<path d="M2 18H18V10H2V18Z" fill="currentColor" fill-opacity="0.1"/>
|
||||
<path d="M2 10V18H18V10M2 10V2H18V10M2 10H18M5 6H9M5 14H9" stroke="currentColor"/>`,
|
||||
sidebar: `<path d="M7.86667 2H5.2H2V18H5.2H7.86667M7.86667 2H18V18H7.86667M7.86667 2V18" stroke="currentColor"/>`,
|
||||
"sidebar-active": `<path d="M2 2V18H5.2H7.86667V2H5.2H2Z" fill="currentColor" fill-opacity="0.1"/>
|
||||
<path d="M7.86667 2H5.2H2V18H5.2H7.86667M7.86667 2H18V18H7.86667M7.86667 2V18" stroke="currentColor"/>`,
|
||||
"layout-left": `<path d="M2.91675 2.91699L2.91675 2.41699L2.41675 2.41699L2.41675 2.91699L2.91675 2.91699ZM17.0834 2.91699L17.5834 2.91699L17.5834 2.41699L17.0834 2.41699L17.0834 2.91699ZM17.0834 17.0837L17.0834 17.5837L17.5834 17.5837L17.5834 17.0837L17.0834 17.0837ZM2.91675 17.0837L2.41675 17.0837L2.41675 17.5837L2.91675 17.5837L2.91675 17.0837ZM7.41674 17.0837L7.41674 17.5837L8.41674 17.5837L8.41674 17.0837L7.91674 17.0837L7.41674 17.0837ZM8.41674 2.91699L8.41674 2.41699L7.41674 2.41699L7.41674 2.91699L7.91674 2.91699L8.41674 2.91699ZM2.91675 2.91699L2.91675 3.41699L17.0834 3.41699L17.0834 2.91699L17.0834 2.41699L2.91675 2.41699L2.91675 2.91699ZM17.0834 2.91699L16.5834 2.91699L16.5834 17.0837L17.0834 17.0837L17.5834 17.0837L17.5834 2.91699L17.0834 2.91699ZM17.0834 17.0837L17.0834 16.5837L2.91675 16.5837L2.91675 17.0837L2.91675 17.5837L17.0834 17.5837L17.0834 17.0837ZM2.91675 17.0837L3.41675 17.0837L3.41675 2.91699L2.91675 2.91699L2.41675 2.91699L2.41675 17.0837L2.91675 17.0837ZM7.91674 17.0837L8.41674 17.0837L8.41674 2.91699L7.91674 2.91699L7.41674 2.91699L7.41674 17.0837L7.91674 17.0837Z" fill="currentColor"/>`,
|
||||
"layout-left-partial": `<path d="M2.91732 2.91602L7.91732 2.91602L7.91732 17.0827H2.91732L2.91732 2.91602Z" fill="currentColor" fill-opacity="16%" /><path d="M2.91732 2.91602L17.084 2.91602M2.91732 2.91602L2.91732 17.0827M2.91732 2.91602L7.91732 2.91602M17.084 2.91602L17.084 17.0827M17.084 2.91602L7.91732 2.91602M17.084 17.0827L2.91732 17.0827M17.084 17.0827L7.91732 17.0827M2.91732 17.0827H7.91732M7.91732 17.0827L7.91732 2.91602" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
"layout-left-full": `<path d="M2.91732 2.91602L7.91732 2.91602L7.91732 17.0827H2.91732L2.91732 2.91602Z" fill="currentColor"/><path d="M2.91732 2.91602L17.084 2.91602M2.91732 2.91602L2.91732 17.0827M2.91732 2.91602L7.91732 2.91602M17.084 2.91602L17.084 17.0827M17.084 2.91602L7.91732 2.91602M17.084 17.0827L2.91732 17.0827M17.084 17.0827L7.91732 17.0827M2.91732 17.0827H7.91732M7.91732 17.0827L7.91732 2.91602" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
"layout-right": `<path d="M2.91536 2.91406H2.36536V2.36406H2.91536V2.91406ZM2.91536 17.0807V17.6307H2.36536V17.0807H2.91536ZM17.082 17.0807H17.632V17.6307H17.082V17.0807ZM17.082 2.91406V2.36406H17.632V2.91406H17.082ZM6.9987 2.91406H6.4487V2.36406H6.9987V2.91406ZM6.9987 17.0807V17.6307H6.4487V17.0807H6.9987ZM2.91536 2.91406H3.46536V17.0807H2.91536H2.36536V2.91406H2.91536ZM2.91536 17.0807V16.5307H17.082V17.0807V17.6307H2.91536V17.0807ZM17.082 17.0807H16.532V2.91406H17.082H17.632V17.0807H17.082ZM17.082 2.91406V3.46406H2.91536V2.91406V2.36406H17.082V2.91406ZM6.9987 2.91406H7.5487V17.0807H6.9987H6.4487V2.91406H6.9987ZM17.082 17.0807L17.082 17.6307L6.9987 17.6307V17.0807V16.5307L17.082 16.5307L17.082 17.0807ZM6.9987 2.91406V2.36406H17.082V2.91406V3.46406H6.9987V2.91406Z" fill="currentColor"/>`,
|
||||
"layout-right-partial": `<path d="M17.082 17.0807L6.9987 17.0807V2.91406H17.082V17.0807Z" fill="currentColor" fill-opacity="16%" /><path d="M2.91536 2.91406H2.36536V2.36406H2.91536V2.91406ZM2.91536 17.0807V17.6307H2.36536V17.0807H2.91536ZM17.082 17.0807H17.632V17.6307H17.082V17.0807ZM17.082 2.91406V2.36406H17.632V2.91406H17.082ZM6.9987 2.91406H6.4487V2.36406H6.9987V2.91406ZM6.9987 17.0807V17.6307H6.4487V17.0807H6.9987ZM2.91536 2.91406H3.46536V17.0807H2.91536H2.36536V2.91406H2.91536ZM2.91536 17.0807V16.5307H17.082V17.0807V17.6307H2.91536V17.0807ZM17.082 17.0807H16.532V2.91406H17.082H17.632V17.0807H17.082ZM17.082 2.91406V3.46406H2.91536V2.91406V2.36406H17.082V2.91406ZM6.9987 2.91406H7.5487V17.0807H6.9987H6.4487V2.91406H6.9987ZM17.082 17.0807L17.082 17.6307L6.9987 17.6307V17.0807V16.5307L17.082 16.5307L17.082 17.0807ZM6.9987 2.91406V2.36406H17.082V2.91406V3.46406H6.9987V2.91406Z" fill="currentColor" />`,
|
||||
"layout-right-full": `<path d="M17.082 17.0807L6.9987 17.0807V2.91406H17.082V17.0807Z" fill="currentColor" /><path d="M2.91536 2.91406H2.36536V2.36406H2.91536V2.91406ZM2.91536 17.0807V17.6307H2.36536V17.0807H2.91536ZM17.082 17.0807H17.632V17.6307H17.082V17.0807ZM17.082 2.91406V2.36406H17.632V2.91406H17.082ZM6.9987 2.91406H6.4487V2.36406H6.9987V2.91406ZM6.9987 17.0807V17.6307H6.4487V17.0807H6.9987ZM2.91536 2.91406H3.46536V17.0807H2.91536H2.36536V2.91406H2.91536ZM2.91536 17.0807V16.5307H17.082V17.0807V17.6307H2.91536V17.0807ZM17.082 17.0807H16.532V2.91406H17.082H17.632V17.0807H17.082ZM17.082 2.91406V3.46406H2.91536V2.91406V2.36406H17.082V2.91406ZM6.9987 2.91406H7.5487V17.0807H6.9987H6.4487V2.91406H6.9987ZM17.082 17.0807L17.082 17.6307L6.9987 17.6307V17.0807V16.5307L17.082 16.5307L17.082 17.0807ZM6.9987 2.91406V2.36406H17.082V2.91406V3.46406H6.9987V2.91406Z" fill="currentColor" />`,
|
||||
"square-arrow-top-right": `<path d="M7.91675 2.9165H2.91675V17.0832H17.0834V12.0832M12.0834 2.9165H17.0834V7.9165M9.58342 10.4165L16.6667 3.33317" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
"open-file": `<path d="M7.91602 2.91406H2.91602V17.0807H17.0827V12.0807M12.0827 2.91406H17.0827V7.91406M9.58268 10.4141L16.666 3.33073" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
"speech-bubble": `<path d="M18.3334 10.0003C18.3334 5.57324 15.0927 2.91699 10.0001 2.91699C4.90749 2.91699 1.66675 5.57324 1.66675 10.0003C1.66675 11.1497 2.45578 13.1016 2.5771 13.3949C2.5878 13.4207 2.59839 13.4444 2.60802 13.4706C2.69194 13.6996 3.04282 14.9364 1.66675 16.7684C3.5186 17.6538 5.48526 16.1982 5.48526 16.1982C6.84592 16.9202 8.46491 17.0837 10.0001 17.0837C15.0927 17.0837 18.3334 14.4274 18.3334 10.0003Z" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
comment: `<path d="M16.25 3.75H3.75V16.25L6.875 14.4643H16.25V3.75Z" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
"folder-add-left": `<path d="M2.08333 9.58268V2.91602H8.33333L10 5.41602H17.9167V16.2493H8.75M3.75 12.0827V14.5827M3.75 14.5827V17.0827M3.75 14.5827H1.25M3.75 14.5827H6.25" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
github: `<path d="M10.0001 1.62549C14.6042 1.62549 18.3334 5.35465 18.3334 9.95882C18.333 11.7049 17.785 13.4068 16.7666 14.8251C15.7482 16.2434 14.3107 17.3066 12.6563 17.8651C12.2397 17.9484 12.0834 17.688 12.0834 17.4692C12.0834 17.188 12.0938 16.2922 12.0938 15.1776C12.0938 14.3963 11.8334 13.8963 11.5313 13.6359C13.3855 13.4276 15.3334 12.7192 15.3334 9.52132C15.3334 8.60465 15.0105 7.86507 14.4792 7.28174C14.5626 7.0734 14.8542 6.21924 14.3959 5.0734C14.3959 5.0734 13.698 4.84424 12.1042 5.92757C11.4376 5.74007 10.7292 5.64632 10.0209 5.64632C9.31258 5.64632 8.60425 5.74007 7.93758 5.92757C6.34383 4.85465 5.64592 5.0734 5.64592 5.0734C5.18758 6.21924 5.47925 7.0734 5.56258 7.28174C5.03133 7.86507 4.70842 8.61507 4.70842 9.52132C4.70842 12.7088 6.64592 13.4276 8.50008 13.6359C8.2605 13.8442 8.04175 14.2088 7.96883 14.7505C7.48967 14.9692 6.29175 15.3234 5.54175 14.063C5.3855 13.813 4.91675 13.1984 4.2605 13.2088C3.56258 13.2192 3.97925 13.6047 4.27092 13.7609C4.62508 13.9588 5.03133 14.6984 5.12508 14.938C5.29175 15.4067 5.83342 16.3026 7.92717 15.9172C7.92717 16.6151 7.93758 17.2713 7.93758 17.4692C7.93758 17.688 7.78133 17.938 7.36467 17.8651C5.70491 17.3126 4.26126 16.2515 3.23851 14.8324C2.21576 13.4133 1.66583 11.7081 1.66675 9.95882C1.66675 5.35465 5.39592 1.62549 10.0001 1.62549Z" fill="currentColor"/>`,
|
||||
discord: `<path d="M16.0742 4.45014C14.9244 3.92097 13.7106 3.54556 12.4638 3.3335C12.2932 3.64011 12.1388 3.95557 12.0013 4.27856C10.6732 4.07738 9.32261 4.07738 7.99451 4.27856C7.85694 3.9556 7.70257 3.64014 7.53203 3.3335C6.28441 3.54735 5.06981 3.92365 3.91889 4.45291C1.63401 7.85128 1.01462 11.1652 1.32431 14.4322C2.6624 15.426 4.16009 16.1819 5.7523 16.6668C6.11082 16.1821 6.42806 15.6678 6.70066 15.1295C6.18289 14.9351 5.68315 14.6953 5.20723 14.4128C5.33249 14.3215 5.45499 14.2274 5.57336 14.136C6.95819 14.7907 8.46965 15.1302 9.99997 15.1302C11.5303 15.1302 13.0418 14.7907 14.4266 14.136C14.5463 14.2343 14.6688 14.3284 14.7927 14.4128C14.3159 14.6957 13.8152 14.9361 13.2965 15.1309C13.5688 15.669 13.8861 16.1828 14.2449 16.6668C15.8385 16.1838 17.3373 15.4283 18.6756 14.4335C19.039 10.645 18.0549 7.36145 16.0742 4.45014ZM7.09294 12.423C6.22992 12.423 5.51693 11.6357 5.51693 10.6671C5.51693 9.69852 6.20514 8.90427 7.09019 8.90427C7.97524 8.90427 8.68272 9.69852 8.66758 10.6671C8.65244 11.6357 7.97248 12.423 7.09294 12.423ZM12.907 12.423C12.0426 12.423 11.3324 11.6357 11.3324 10.6671C11.3324 9.69852 12.0206 8.90427 12.907 8.90427C13.7934 8.90427 14.4954 9.69852 14.4803 10.6671C14.4651 11.6357 13.7865 12.423 12.907 12.423Z" fill="currentColor"/>`,
|
||||
"layout-bottom": `<path d="M2.91699 17.0832L2.41699 17.0832L2.41699 17.5832L2.91699 17.5832L2.91699 17.0832ZM2.91699 2.91653L2.91699 2.41653L2.41699 2.41653L2.41699 2.91653L2.91699 2.91653ZM17.0837 2.91653L17.5837 2.91653L17.5837 2.41653L17.0837 2.41653L17.0837 2.91653ZM17.0837 17.0832L17.5837 17.0832L17.5837 17.5832L17.0837 17.5832L17.0837 17.0832ZM17.0837 12.5827L17.5837 12.5827L17.5837 11.5827L17.0837 11.5827L17.0837 12.0827L17.0837 12.5827ZM2.91699 11.5827L2.41699 11.5827L2.41699 12.5827L2.91699 12.5827L2.91699 12.0827L2.91699 11.5827ZM2.91699 17.0832L3.41699 17.0832L3.41699 2.91653L2.91699 2.91653L2.41699 2.91653L2.41699 17.0832L2.91699 17.0832ZM2.91699 2.91653L2.91699 3.41653L17.0837 3.41653L17.0837 2.91653L17.0837 2.41653L2.91699 2.41653L2.91699 2.91653ZM17.0837 2.91653L16.5837 2.91653L16.5837 17.0832L17.0837 17.0832L17.5837 17.0832L17.5837 2.91653L17.0837 2.91653ZM17.0837 17.0832L17.0837 16.5832L2.91699 16.5832L2.91699 17.0832L2.91699 17.5832L17.0837 17.5832L17.0837 17.0832ZM17.0837 12.0827L17.0837 11.5827L2.91699 11.5827L2.91699 12.0827L2.91699 12.5827L17.0837 12.5827L17.0837 12.0827Z" fill="currentColor"/>`,
|
||||
"layout-bottom-partial": `<path d="M2.91732 12.0827L17.084 12.0827L17.084 17.0827H2.91732L2.91732 12.0827Z" fill="currentColor" fill-opacity="16%" /><path d="M2.91732 2.91602L17.084 2.91602M2.91732 2.91602L2.91732 17.0827M17.084 2.91602L17.084 17.0827M17.084 17.0827L2.91732 17.0827M2.91732 12.0827L17.084 12.0827" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
"layout-bottom-full": `<path d="M2.91732 12.0827L17.084 12.0827L17.084 17.0827H2.91732L2.91732 12.0827Z" fill="currentColor"/><path d="M2.91732 2.91602L17.084 2.91602M2.91732 2.91602L2.91732 17.0827M17.084 2.91602L17.084 17.0827M17.084 17.0827L2.91732 17.0827M2.91732 12.0827L17.084 12.0827" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
"dot-grid": `<path d="M2.08398 9.16602H3.75065V10.8327H2.08398V9.16602Z" fill="currentColor"/><path d="M10.834 9.16602H9.16732V10.8327H10.834V9.16602Z" fill="currentColor"/><path d="M16.2507 9.16602H17.9173V10.8327H16.2507V9.16602Z" fill="currentColor"/><path d="M2.08398 9.16602H3.75065V10.8327H2.08398V9.16602Z" stroke="currentColor"/><path d="M10.834 9.16602H9.16732V10.8327H10.834V9.16602Z" stroke="currentColor"/><path d="M16.2507 9.16602H17.9173V10.8327H16.2507V9.16602Z" stroke="currentColor"/>`,
|
||||
"circle-check": `<path d="M12.4987 7.91732L8.7487 12.5007L7.08203 10.834M17.9154 10.0007C17.9154 14.3729 14.371 17.9173 9.9987 17.9173C5.62644 17.9173 2.08203 14.3729 2.08203 10.0007C2.08203 5.6284 5.62644 2.08398 9.9987 2.08398C14.371 2.08398 17.9154 5.6284 17.9154 10.0007Z" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
copy: `<path d="M6.2513 6.24935V2.91602H17.0846V13.7493H13.7513M13.7513 6.24935V17.0827H2.91797V6.24935H13.7513Z" stroke="currentColor" stroke-linecap="round"/>`,
|
||||
check: `<path d="M5 11.9657L8.37838 14.7529L15 5.83398" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
photo: `<path d="M16.6665 16.6666L11.6665 11.6666L9.99984 13.3333L6.6665 9.99996L3.08317 13.5833M2.9165 2.91663H17.0832V17.0833H2.9165V2.91663ZM13.3332 7.49996C13.3332 8.30537 12.6803 8.95829 11.8748 8.95829C11.0694 8.95829 10.4165 8.30537 10.4165 7.49996C10.4165 6.69454 11.0694 6.04163 11.8748 6.04163C12.6803 6.04163 13.3332 6.69454 13.3332 7.49996Z" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
share: `<path d="M10.0013 12.0846L10.0013 3.33464M13.7513 6.66797L10.0013 2.91797L6.2513 6.66797M17.0846 10.418V17.0846H2.91797V10.418" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
shield: `<path d="M7.49935 9.3737L9.16602 11.0404L12.4994 7.70703M9.99935 2.08203L17.0827 4.3737V9.92565C17.0827 14.0694 13.3327 16.2487 9.99935 18.047C6.66602 16.2487 2.91602 14.0694 2.91602 9.92565V4.3737L9.99935 2.08203Z" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
download: `<path d="M13.9583 10.6257L10 14.584L6.04167 10.6257M10 2.08398V13.959M16.25 17.9173H3.75" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
menu: `<path d="M2.5 5H17.5M2.5 10H17.5M2.5 15H17.5" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
server: `<rect x="3.35547" y="1.92969" width="13.2857" height="16.1429" stroke="currentColor"/><rect x="3.35547" y="11.9297" width="13.2857" height="6.14286" stroke="currentColor"/><rect x="12.8555" y="14.2852" width="1.42857" height="1.42857" fill="currentColor"/><rect x="10" y="14.2852" width="1.42857" height="1.42857" fill="currentColor"/>`,
|
||||
branch: `<path d="M14.2036 7.19987L14.2079 6.69989L13.2079 6.69132L13.2036 7.1913L13.7036 7.19559L14.2036 7.19987ZM8.14804 5.09032H7.64804C7.64804 5.75797 7.06861 6.34471 6.29619 6.34471V6.84471V7.34471C7.56926 7.34471 8.64804 6.36051 8.64804 5.09032H8.14804ZM6.29619 6.84471V6.34471C5.52376 6.34471 4.94434 5.75797 4.94434 5.09032H4.44434H3.94434C3.94434 6.36051 5.02311 7.34471 6.29619 7.34471V6.84471ZM4.44434 5.09032H4.94434C4.94434 4.42267 5.52376 3.83594 6.29619 3.83594V3.33594V2.83594C5.02311 2.83594 3.94434 3.82013 3.94434 5.09032H4.44434ZM6.29619 3.33594V3.83594C7.06861 3.83594 7.64804 4.42267 7.64804 5.09032H8.14804H8.64804C8.64804 3.82013 7.56926 2.83594 6.29619 2.83594V3.33594ZM8.14804 14.9149H7.64804C7.64804 15.5825 7.06861 16.1693 6.29619 16.1693V16.6693V17.1693C7.56926 17.1693 8.64804 16.1851 8.64804 14.9149H8.14804ZM6.29619 16.6693V16.1693C5.52376 16.1693 4.94434 15.5825 4.94434 14.9149H4.44434H3.94434C3.94434 16.1851 5.02311 17.1693 6.29619 17.1693V16.6693ZM4.44434 14.9149H4.94434C4.94434 14.2472 5.52376 13.6605 6.29619 13.6605V13.1605V12.6605C5.02311 12.6605 3.94434 13.6447 3.94434 14.9149H4.44434ZM6.29619 13.1605V13.6605C7.06861 13.6605 7.64804 14.2472 7.64804 14.9149H8.14804H8.64804C8.64804 13.6447 7.56926 12.6605 6.29619 12.6605V13.1605ZM15.5554 5.09032H15.0554C15.0554 5.75797 14.476 6.34471 13.7036 6.34471V6.84471V7.34471C14.9767 7.34471 16.0554 6.36051 16.0554 5.09032H15.5554ZM13.7036 6.84471V6.34471C12.9312 6.34471 12.3517 5.75797 12.3517 5.09032H11.8517H11.3517C11.3517 6.36051 12.4305 7.34471 13.7036 7.34471V6.84471ZM11.8517 5.09032H12.3517C12.3517 4.42267 12.9312 3.83594 13.7036 3.83594V3.33594V2.83594C12.4305 2.83594 11.3517 3.82013 11.3517 5.09032H11.8517ZM13.7036 3.33594V3.83594C14.476 3.83594 15.0554 4.42267 15.0554 5.09032H15.5554H16.0554C16.0554 3.82013 14.9767 2.83594 13.7036 2.83594V3.33594ZM13.7036 7.19559L13.2036 7.1913L13.1544 12.9277L13.6544 12.932L14.1544 12.9363L14.2036 7.19987L13.7036 7.19559ZM6.29619 6.84471H5.79619V13.1605H6.29619H6.79619V6.84471H6.29619ZM11.6545 14.9149V14.4149H8.14804V14.9149V15.4149H11.6545V14.9149ZM13.6544 12.932L13.1544 12.9277C13.1474 13.7511 12.4779 14.4149 11.6545 14.4149V14.9149V15.4149C13.0269 15.4149 14.1426 14.3086 14.1544 12.9363L13.6544 12.932Z" fill="currentColor"/>`,
|
||||
edit: `<path d="M17.0832 17.0807V17.5807H17.5832V17.0807H17.0832ZM2.9165 17.0807H2.4165V17.5807H2.9165V17.0807ZM2.9165 2.91406V2.41406H2.4165V2.91406H2.9165ZM9.58317 3.41406H10.0832V2.41406H9.58317V2.91406V3.41406ZM17.5832 10.4141V9.91406H16.5832V10.4141H17.0832H17.5832ZM6.24984 11.2474L5.89628 10.8938L5.74984 11.0403V11.2474H6.24984ZM6.24984 13.7474H5.74984V14.2474H6.24984V13.7474ZM8.74984 13.7474V14.2474H8.95694L9.10339 14.101L8.74984 13.7474ZM15.2082 2.28906L15.5617 1.93551L15.2082 1.58196L14.8546 1.93551L15.2082 2.28906ZM17.7082 4.78906L18.0617 5.14262L18.4153 4.78906L18.0617 4.43551L17.7082 4.78906ZM17.0832 17.0807V16.5807H2.9165V17.0807V17.5807H17.0832V17.0807ZM2.9165 17.0807H3.4165V2.91406H2.9165H2.4165V17.0807H2.9165ZM2.9165 2.91406V3.41406H9.58317V2.91406V2.41406H2.9165V2.91406ZM17.0832 10.4141H16.5832V17.0807H17.0832H17.5832V10.4141H17.0832ZM6.24984 11.2474H5.74984V13.7474H6.24984H6.74984V11.2474H6.24984ZM6.24984 13.7474V14.2474H8.74984V13.7474V13.2474H6.24984V13.7474ZM6.24984 11.2474L6.60339 11.6009L15.5617 2.64262L15.2082 2.28906L14.8546 1.93551L5.89628 10.8938L6.24984 11.2474ZM15.2082 2.28906L14.8546 2.64262L17.3546 5.14262L17.7082 4.78906L18.0617 4.43551L15.5617 1.93551L15.2082 2.28906ZM17.7082 4.78906L17.3546 4.43551L8.39628 13.3938L8.74984 13.7474L9.10339 14.101L18.0617 5.14262L17.7082 4.78906Z" fill="currentColor"/>`,
|
||||
help: `<path d="M7.91683 7.91927V6.2526H12.0835V8.7526L10.0002 10.0026V12.0859M10.0002 13.7526V13.7609M17.9168 10.0026C17.9168 14.3749 14.3724 17.9193 10.0002 17.9193C5.62791 17.9193 2.0835 14.3749 2.0835 10.0026C2.0835 5.63035 5.62791 2.08594 10.0002 2.08594C14.3724 2.08594 17.9168 5.63035 17.9168 10.0026Z" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
"settings-gear": `<path d="M7.62516 4.46094L5.05225 3.86719L3.86475 5.05469L4.4585 7.6276L2.0835 9.21094V10.7943L4.4585 12.3776L3.86475 14.9505L5.05225 16.138L7.62516 15.5443L9.2085 17.9193H10.7918L12.3752 15.5443L14.9481 16.138L16.1356 14.9505L15.5418 12.3776L17.9168 10.7943V9.21094L15.5418 7.6276L16.1356 5.05469L14.9481 3.86719L12.3752 4.46094L10.7918 2.08594H9.2085L7.62516 4.46094Z" stroke="currentColor"/><path d="M12.5002 10.0026C12.5002 11.3833 11.3809 12.5026 10.0002 12.5026C8.61945 12.5026 7.50016 11.3833 7.50016 10.0026C7.50016 8.62189 8.61945 7.5026 10.0002 7.5026C11.3809 7.5026 12.5002 8.62189 12.5002 10.0026Z" stroke="currentColor"/>`,
|
||||
dash: `<rect x="5" y="9.5" width="10" height="1" fill="currentColor"/>`,
|
||||
"cloud-upload": `<path d="M12.0833 16.25H15C17.0711 16.25 18.75 14.5711 18.75 12.5C18.75 10.5649 17.2843 8.97217 15.4025 8.77133C15.2 6.13103 12.8586 4.08333 10 4.08333C7.71532 4.08333 5.76101 5.49781 4.96501 7.49881C2.84892 7.90461 1.25 9.76559 1.25 11.6667C1.25 13.9813 3.30203 16.25 5.83333 16.25H7.91667M10 16.25V10.4167M12.0833 11.875L10 9.79167L7.91667 11.875" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
trash: `<path d="M4.58342 17.9134L4.58369 17.4134L4.22787 17.5384L4.22766 18.0384H4.58342V17.9134ZM15.4167 17.9134V18.0384H15.7725L15.7723 17.5384L15.4167 17.9134ZM2.08342 3.95508V3.45508H1.58342V3.95508H2.08342V4.45508V3.95508ZM17.9167 4.45508V4.95508H18.4167V4.45508H17.9167V3.95508V4.45508ZM4.16677 4.58008L3.66701 4.5996L4.22816 17.5379L4.72792 17.4934L5.22767 17.4489L4.66652 4.54055L4.16677 4.58008ZM4.58342 18.0384V17.9134H15.4167V18.0384V18.5384H4.58342V18.0384ZM15.4167 17.9134L15.8332 17.5379L16.2498 4.5996L15.7501 4.58008L15.2503 4.56055L14.8337 17.4989L15.4167 17.9134ZM15.8334 4.58008V4.08008H4.16677V4.58008V5.08008H15.8334V4.58008ZM2.08342 4.45508V4.95508H4.16677V4.58008V4.08008H2.08342V4.45508ZM15.8334 4.58008V5.08008H17.9167V4.45508V3.95508H15.8334V4.58008ZM6.83951 4.35149L7.432 4.55047C7.79251 3.47701 8.80699 2.70508 10.0001 2.70508V2.20508V1.70508C8.25392 1.70508 6.77335 2.83539 6.24702 4.15251L6.83951 4.35149ZM10.0001 2.20508V2.70508C11.1932 2.70508 12.2077 3.47701 12.5682 4.55047L13.1607 4.35149L13.7532 4.15251C13.2269 2.83539 11.7463 1.70508 10.0001 1.70508V2.20508Z" fill="currentColor"/>`,
|
||||
sliders: `<path d="M3.625 6.25H10.9375M16.375 13.75H10.5625M3.625 13.75H4.9375M11.125 6.25C11.125 4.79969 12.2997 3.625 13.75 3.625C15.2003 3.625 16.375 4.79969 16.375 6.25C16.375 7.70031 15.2003 8.875 13.75 8.875C12.2997 8.875 11.125 7.70031 11.125 6.25ZM10.375 13.75C10.375 15.2003 9.20031 16.375 7.75 16.375C6.29969 16.375 5.125 15.2003 5.125 13.75C5.125 12.2997 6.29969 11.125 7.75 11.125C9.20031 11.125 10.375 12.2997 10.375 13.75Z" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
keyboard: `<path d="M5.125 7.375V4.375H14.875V2.875M8.3125 13.9375H11.6875M8.125 13.9375H11.875M2.125 7.375H17.875V17.125H2.125V7.375ZM5.5 10.375H5.125V10.75H5.5V10.375ZM8.5 10.375H8.125V10.75H8.5V10.375ZM11.875 10.375H11.5V10.75H11.875V10.375ZM14.875 10.375H14.5V10.75H14.875V10.375ZM14.875 13.75H14.5V14.125H14.875V13.75ZM5.5 13.75H5.125V14.125H5.5V13.75Z" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
selector: `<path d="M6.66626 12.5033L9.99959 15.8366L13.3329 12.5033M6.66626 7.50326L9.99959 4.16992L13.3329 7.50326" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
"arrow-down-to-line": `<path d="M15.2083 11.6667L10 16.875L4.79167 11.6667M10 16.25V3.125" stroke="currentColor" stroke-width="1.25" stroke-linecap="square"/>`,
|
||||
warning: `<path d="M10 7.91667V11.6667M10 13.7417V13.75M10 2.5L1.875 16.25H18.125L10 2.5Z" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
reset: `<path d="M5.83333 4.16406L2.5 7.4974L5.83333 10.8307M3.33333 7.4974H17.9167V15.4141H10" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
link: `<path d="M2.08334 12.0833L1.72979 11.7298L1.37624 12.0833L1.72979 12.4369L2.08334 12.0833ZM7.91668 17.9167L7.56312 18.2702L7.91668 18.6238L8.27023 18.2702L7.91668 17.9167ZM17.9167 7.91666L18.2702 8.27022L18.6238 7.91666L18.2702 7.56311L17.9167 7.91666ZM12.0833 2.08333L12.4369 1.72977L12.0833 1.37622L11.7298 1.72977L12.0833 2.08333ZM8.39646 5.06311L8.0429 5.41666L8.75001 6.12377L9.10356 5.77021L8.75001 5.41666L8.39646 5.06311ZM5.77023 9.10355L6.12378 8.74999L5.41668 8.04289L5.06312 8.39644L5.41668 8.74999L5.77023 9.10355ZM14.2298 10.8964L13.8762 11.25L14.5833 11.9571L14.9369 11.6035L14.5833 11.25L14.2298 10.8964ZM11.6036 14.9369L11.9571 14.5833L11.25 13.8762L10.8965 14.2298L11.25 14.5833L11.6036 14.9369ZM7.14646 12.1464L6.7929 12.5L7.50001 13.2071L7.85356 12.8535L7.50001 12.5L7.14646 12.1464ZM12.8536 7.85355L13.2071 7.49999L12.5 6.79289L12.1465 7.14644L12.5 7.49999L12.8536 7.85355ZM2.08334 12.0833L1.72979 12.4369L7.56312 18.2702L7.91668 17.9167L8.27023 17.5631L2.4369 11.7298L2.08334 12.0833ZM17.9167 7.91666L18.2702 7.56311L12.4369 1.72977L12.0833 2.08333L11.7298 2.43688L17.5631 8.27022L17.9167 7.91666ZM12.0833 2.08333L11.7298 1.72977L8.39646 5.06311L8.75001 5.41666L9.10356 5.77021L12.4369 2.43688L12.0833 2.08333ZM5.41668 8.74999L5.06312 8.39644L1.72979 11.7298L2.08334 12.0833L2.4369 12.4369L5.77023 9.10355L5.41668 8.74999ZM14.5833 11.25L14.9369 11.6035L18.2702 8.27022L17.9167 7.91666L17.5631 7.56311L14.2298 10.8964L14.5833 11.25ZM7.91668 17.9167L8.27023 18.2702L11.6036 14.9369L11.25 14.5833L10.8965 14.2298L7.56312 17.5631L7.91668 17.9167ZM7.50001 12.5L7.85356 12.8535L12.8536 7.85355L12.5 7.49999L12.1465 7.14644L7.14646 12.1464L7.50001 12.5Z" fill="currentColor"/>`,
|
||||
providers: `<path d="M10.0001 4.37562V2.875M13 4.37793V2.87793M7.00014 4.37793V2.875M10 17.1279V15.6279M13 17.1279V15.6279M7 17.1279V15.6279M15.625 13.0029H17.125M15.625 7.00293H17.125M15.625 10.0029H17.125M2.875 10.0029H4.375M2.875 13.0029H4.375M2.875 7.00293H4.375M4.375 4.37793H15.625V15.6279H4.375V4.37793ZM12.6241 10.0022C12.6241 11.4519 11.4488 12.6272 9.99908 12.6272C8.54934 12.6272 7.37408 11.4519 7.37408 10.0022C7.37408 8.55245 8.54934 7.3772 9.99908 7.3772C11.4488 7.3772 12.6241 8.55245 12.6241 10.0022Z" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
models: `<path fill-rule="evenodd" clip-rule="evenodd" d="M17.5 10C12.2917 10 10 12.2917 10 17.5C10 12.2917 7.70833 10 2.5 10C7.70833 10 10 7.70833 10 2.5C10 7.70833 12.2917 10 17.5 10Z" stroke="currentColor"/>`,
|
||||
"arrow-undo-down": `<path d="M4.08333 11.0859L1.75 8.7526L4.08333 6.41927M2.33333 8.7526L12.5417 8.7526L12.5417 3.21094L7 3.21094" stroke="currentColor" stroke-width="1" stroke-linecap="square"/>`,
|
||||
}
|
||||
|
||||
export interface IconProps extends ComponentProps<"svg"> {
|
||||
name: keyof typeof icons
|
||||
size?: "small" | "normal" | "medium" | "large"
|
||||
}
|
||||
|
||||
export function Icon(props: IconProps) {
|
||||
const [local, others] = splitProps(props, ["name", "size", "class", "classList"])
|
||||
const viewBox = () =>
|
||||
local.name === "magnifying-glass" || local.name === "arrow-undo-down" ? "0 0 16 16" : "0 0 20 20"
|
||||
return (
|
||||
<div data-component="icon" data-size={local.size || "normal"}>
|
||||
<svg
|
||||
data-slot="icon-svg"
|
||||
classList={{
|
||||
...local.classList,
|
||||
[local.class ?? ""]: !!local.class,
|
||||
}}
|
||||
fill="none"
|
||||
viewBox={viewBox()}
|
||||
innerHTML={icons[local.name as keyof typeof icons]}
|
||||
aria-hidden="true"
|
||||
{...others}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
63
qimingcode/packages/ui/src/components/image-preview.css
Normal file
63
qimingcode/packages/ui/src/components/image-preview.css
Normal file
@@ -0,0 +1,63 @@
|
||||
[data-component="image-preview"] {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
[data-slot="image-preview-container"] {
|
||||
position: relative;
|
||||
z-index: 50;
|
||||
width: min(calc(100vw - 32px), 90vw);
|
||||
max-width: 1200px;
|
||||
height: min(calc(100vh - 32px), 90vh);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
[data-slot="image-preview-content"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
max-height: 100%;
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--surface-raised-stronger-non-alpha);
|
||||
box-shadow:
|
||||
0 15px 45px 0 rgba(19, 16, 16, 0.35),
|
||||
0 3.35px 10.051px 0 rgba(19, 16, 16, 0.25),
|
||||
0 0.998px 2.993px 0 rgba(19, 16, 16, 0.2);
|
||||
overflow: hidden;
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
[data-slot="image-preview-header"] {
|
||||
display: flex;
|
||||
padding: 8px 8px 0;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
[data-slot="image-preview-body"] {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
[data-slot="image-preview-image"] {
|
||||
max-width: 100%;
|
||||
max-height: calc(90vh - 100px);
|
||||
object-fit: contain;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// @ts-nocheck
|
||||
import { onMount } from "solid-js"
|
||||
import * as mod from "./image-preview"
|
||||
import { Button } from "./button"
|
||||
import { useDialog } from "../context/dialog"
|
||||
|
||||
const docs = `### Overview
|
||||
Image preview content intended to render inside the dialog stack.
|
||||
|
||||
Use for full-size image inspection; keep images optimized.
|
||||
|
||||
### API
|
||||
- Required: \`src\`.
|
||||
- Optional: \`alt\` text.
|
||||
|
||||
### Variants and states
|
||||
- Single layout with close action.
|
||||
|
||||
### Behavior
|
||||
- Intended to be used via \`useDialog().show\`.
|
||||
|
||||
### Accessibility
|
||||
- Uses localized aria-label for close button.
|
||||
|
||||
### Theming/tokens
|
||||
- Uses \`data-component="image-preview"\` and slot attributes.
|
||||
|
||||
`
|
||||
|
||||
export default {
|
||||
title: "UI/ImagePreview",
|
||||
id: "components-image-preview",
|
||||
component: mod.ImagePreview,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: docs,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const Basic = {
|
||||
render: () => {
|
||||
const dialog = useDialog()
|
||||
const src = "https://placehold.co/640x360/png"
|
||||
|
||||
const open = () => dialog.show(() => <mod.ImagePreview src={src} alt="Preview" />)
|
||||
|
||||
onMount(open)
|
||||
|
||||
return (
|
||||
<Button variant="secondary" onClick={open}>
|
||||
Open image preview
|
||||
</Button>
|
||||
)
|
||||
},
|
||||
}
|
||||
32
qimingcode/packages/ui/src/components/image-preview.tsx
Normal file
32
qimingcode/packages/ui/src/components/image-preview.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Dialog as Kobalte } from "@kobalte/core/dialog"
|
||||
import { useI18n } from "../context/i18n"
|
||||
import { IconButton } from "./icon-button"
|
||||
|
||||
export interface ImagePreviewProps {
|
||||
src: string
|
||||
alt?: string
|
||||
}
|
||||
|
||||
export function ImagePreview(props: ImagePreviewProps) {
|
||||
const i18n = useI18n()
|
||||
return (
|
||||
<div data-component="image-preview">
|
||||
<div data-slot="image-preview-container">
|
||||
<Kobalte.Content data-slot="image-preview-content">
|
||||
<div data-slot="image-preview-header">
|
||||
<Kobalte.CloseButton
|
||||
data-slot="image-preview-close"
|
||||
as={IconButton}
|
||||
icon="close"
|
||||
variant="ghost"
|
||||
aria-label={i18n.t("ui.common.close")}
|
||||
/>
|
||||
</div>
|
||||
<div data-slot="image-preview-body">
|
||||
<img src={props.src} alt={props.alt ?? i18n.t("ui.imagePreview.alt")} data-slot="image-preview-image" />
|
||||
</div>
|
||||
</Kobalte.Content>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
17
qimingcode/packages/ui/src/components/inline-input.css
Normal file
17
qimingcode/packages/ui/src/components/inline-input.css
Normal file
@@ -0,0 +1,17 @@
|
||||
[data-component="inline-input"] {
|
||||
color: inherit;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0;
|
||||
min-width: 0;
|
||||
font: inherit;
|
||||
letter-spacing: inherit;
|
||||
line-height: inherit;
|
||||
box-sizing: border-box;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
box-shadow: var(--inline-input-shadow, 0 0 0 1px var(--border-interactive-focus));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// @ts-nocheck
|
||||
import * as mod from "./inline-input"
|
||||
import { create } from "../storybook/scaffold"
|
||||
|
||||
const docs = `### Overview
|
||||
Compact inline input for short values.
|
||||
|
||||
Use inside text or table rows for quick edits.
|
||||
|
||||
### API
|
||||
- Optional: \`width\` to set a fixed width.
|
||||
- Accepts standard input props.
|
||||
|
||||
### Variants and states
|
||||
- No built-in variants; style via class or width.
|
||||
|
||||
### Behavior
|
||||
- Uses inline width when provided.
|
||||
|
||||
### Accessibility
|
||||
- Provide a label or aria-label when used standalone.
|
||||
|
||||
### Theming/tokens
|
||||
- Uses \`data-component="inline-input"\`.
|
||||
|
||||
`
|
||||
|
||||
const story = create({ title: "UI/InlineInput", mod, args: { placeholder: "Type...", value: "Inline" } })
|
||||
export default {
|
||||
title: "UI/InlineInput",
|
||||
id: "components-inline-input",
|
||||
component: story.meta.component,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: docs,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const Basic = story.Basic
|
||||
|
||||
export const FixedWidth = {
|
||||
args: {
|
||||
value: "80px",
|
||||
width: "80px",
|
||||
},
|
||||
}
|
||||
22
qimingcode/packages/ui/src/components/inline-input.tsx
Normal file
22
qimingcode/packages/ui/src/components/inline-input.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { ComponentProps } from "solid-js"
|
||||
import { splitProps } from "solid-js"
|
||||
|
||||
export type InlineInputProps = ComponentProps<"input"> & {
|
||||
width?: string
|
||||
}
|
||||
|
||||
export function InlineInput(props: InlineInputProps) {
|
||||
const [local, others] = splitProps(props, ["class", "width", "style"])
|
||||
|
||||
const style = () => {
|
||||
if (!local.style) return { width: local.width }
|
||||
if (typeof local.style === "string") {
|
||||
if (!local.width) return local.style
|
||||
return `${local.style};width:${local.width}`
|
||||
}
|
||||
if (!local.width) return local.style
|
||||
return { ...local.style, width: local.width }
|
||||
}
|
||||
|
||||
return <input data-component="inline-input" class={local.class} style={style()} {...others} />
|
||||
}
|
||||
18
qimingcode/packages/ui/src/components/keybind.css
Normal file
18
qimingcode/packages/ui/src/components/keybind.css
Normal file
@@ -0,0 +1,18 @@
|
||||
[data-component="keybind"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
height: 20px;
|
||||
padding: 0 8px;
|
||||
border-radius: 2px;
|
||||
background: var(--surface-base);
|
||||
box-shadow: var(--shadow-xxs-border);
|
||||
|
||||
/* text-12-regular */
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: 12px;
|
||||
font-weight: var(--font-weight-regular);
|
||||
line-height: 1;
|
||||
color: var(--text-weak);
|
||||
}
|
||||
43
qimingcode/packages/ui/src/components/keybind.stories.tsx
Normal file
43
qimingcode/packages/ui/src/components/keybind.stories.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
// @ts-nocheck
|
||||
import * as mod from "./keybind"
|
||||
import { create } from "../storybook/scaffold"
|
||||
|
||||
const docs = `### Overview
|
||||
Keyboard shortcut pill for displaying keybindings.
|
||||
|
||||
Pair with menu items or command palettes.
|
||||
|
||||
### API
|
||||
- Children render the key sequence text.
|
||||
- Accepts standard span props.
|
||||
|
||||
### Variants and states
|
||||
- Single visual style.
|
||||
|
||||
### Behavior
|
||||
- Presentational only.
|
||||
|
||||
### Accessibility
|
||||
- Ensure text conveys the shortcut (e.g., "Cmd+K").
|
||||
|
||||
### Theming/tokens
|
||||
- Uses \`data-component="keybind"\`.
|
||||
|
||||
`
|
||||
|
||||
const story = create({ title: "UI/Keybind", mod, args: { children: "Cmd+K" } })
|
||||
export default {
|
||||
title: "UI/Keybind",
|
||||
id: "components-keybind",
|
||||
component: story.meta.component,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: docs,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const Basic = story.Basic
|
||||
20
qimingcode/packages/ui/src/components/keybind.tsx
Normal file
20
qimingcode/packages/ui/src/components/keybind.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { ComponentProps, ParentProps } from "solid-js"
|
||||
|
||||
export interface KeybindProps extends ParentProps {
|
||||
class?: string
|
||||
classList?: ComponentProps<"span">["classList"]
|
||||
}
|
||||
|
||||
export function Keybind(props: KeybindProps) {
|
||||
return (
|
||||
<span
|
||||
data-component="keybind"
|
||||
classList={{
|
||||
...props.classList,
|
||||
[props.class ?? ""]: !!props.class,
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,596 @@
|
||||
import { type DiffLineAnnotation, type SelectedLineRange } from "@pierre/diffs"
|
||||
import { createEffect, createMemo, createSignal, onCleanup, Show, type Accessor, type JSX } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { render as renderSolid } from "solid-js/web"
|
||||
import { useI18n } from "../context/i18n"
|
||||
import { createHoverCommentUtility } from "../pierre/comment-hover"
|
||||
import { cloneSelectedLineRange, formatSelectedLineLabel, lineInSelectedRange } from "../pierre/selection-bridge"
|
||||
import { LineComment, LineCommentEditor, type LineCommentEditorProps } from "./line-comment"
|
||||
|
||||
export type LineCommentAnnotationMeta<T> =
|
||||
| { kind: "comment"; key: string; comment: T }
|
||||
| { kind: "draft"; key: string; range: SelectedLineRange }
|
||||
|
||||
export type LineCommentAnnotation<T> = {
|
||||
lineNumber: number
|
||||
side?: "additions" | "deletions"
|
||||
metadata: LineCommentAnnotationMeta<T>
|
||||
}
|
||||
|
||||
type LineCommentAnnotationsProps<T> = {
|
||||
comments: Accessor<T[]>
|
||||
getCommentId: (comment: T) => string
|
||||
getCommentSelection: (comment: T) => SelectedLineRange
|
||||
draftRange: Accessor<SelectedLineRange | null>
|
||||
draftKey: Accessor<string>
|
||||
}
|
||||
|
||||
type LineCommentAnnotationsWithSideProps<T> = LineCommentAnnotationsProps<T> & {
|
||||
getSide: (range: SelectedLineRange) => "additions" | "deletions"
|
||||
}
|
||||
|
||||
type HoverCommentLine = {
|
||||
lineNumber: number
|
||||
side?: "additions" | "deletions"
|
||||
}
|
||||
|
||||
type LineCommentStateProps<T> = {
|
||||
opened: Accessor<T | null>
|
||||
setOpened: (id: T | null) => void
|
||||
selected: Accessor<SelectedLineRange | null>
|
||||
setSelected: (range: SelectedLineRange | null) => void
|
||||
commenting: Accessor<SelectedLineRange | null>
|
||||
setCommenting: (range: SelectedLineRange | null) => void
|
||||
syncSelected?: (range: SelectedLineRange | null) => void
|
||||
hoverSelected?: (range: SelectedLineRange) => void
|
||||
}
|
||||
|
||||
type LineCommentShape = {
|
||||
id: string
|
||||
selection: SelectedLineRange
|
||||
comment: string
|
||||
}
|
||||
|
||||
type LineCommentControllerProps<T extends LineCommentShape> = {
|
||||
comments: Accessor<T[]>
|
||||
draftKey: Accessor<string>
|
||||
label: string
|
||||
mention?: LineCommentEditorProps["mention"]
|
||||
state: LineCommentStateProps<string>
|
||||
onSubmit: (input: { comment: string; selection: SelectedLineRange }) => void
|
||||
onUpdate?: (input: { id: string; comment: string; selection: SelectedLineRange }) => void
|
||||
onDelete?: (comment: T) => void
|
||||
renderCommentActions?: (comment: T, controls: { edit: VoidFunction; remove: VoidFunction }) => JSX.Element
|
||||
editSubmitLabel?: string
|
||||
onDraftPopoverFocusOut?: JSX.EventHandlerUnion<HTMLDivElement, FocusEvent>
|
||||
getHoverSelectedRange?: Accessor<SelectedLineRange | null>
|
||||
cancelDraftOnCommentToggle?: boolean
|
||||
clearSelectionOnSelectionEndNull?: boolean
|
||||
}
|
||||
|
||||
type LineCommentControllerWithSideProps<T extends LineCommentShape> = LineCommentControllerProps<T> & {
|
||||
getSide: (range: SelectedLineRange) => "additions" | "deletions"
|
||||
}
|
||||
|
||||
type CommentProps = {
|
||||
id?: string
|
||||
open: boolean
|
||||
comment: JSX.Element
|
||||
selection: JSX.Element
|
||||
actions?: JSX.Element
|
||||
editor?: DraftProps
|
||||
onClick?: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent>
|
||||
onMouseEnter?: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent>
|
||||
}
|
||||
|
||||
type DraftProps = {
|
||||
value: string
|
||||
selection: JSX.Element
|
||||
mention?: LineCommentEditorProps["mention"]
|
||||
onInput: (value: string) => void
|
||||
onCancel: VoidFunction
|
||||
onSubmit: (value: string) => void
|
||||
onPopoverFocusOut?: JSX.EventHandlerUnion<HTMLDivElement, FocusEvent>
|
||||
cancelLabel?: string
|
||||
submitLabel?: string
|
||||
}
|
||||
|
||||
export function createLineCommentAnnotationRenderer<T>(props: {
|
||||
renderComment: (comment: T) => CommentProps
|
||||
renderDraft: (range: SelectedLineRange) => DraftProps
|
||||
}) {
|
||||
const nodes = new Map<
|
||||
string,
|
||||
{
|
||||
host: HTMLDivElement
|
||||
dispose: VoidFunction
|
||||
setMeta: (meta: LineCommentAnnotationMeta<T>) => void
|
||||
}
|
||||
>()
|
||||
|
||||
const mount = (meta: LineCommentAnnotationMeta<T>) => {
|
||||
if (typeof document === "undefined") return
|
||||
|
||||
const host = document.createElement("div")
|
||||
host.setAttribute("data-prevent-autofocus", "")
|
||||
const [current, setCurrent] = createSignal(meta)
|
||||
|
||||
const dispose = renderSolid(() => {
|
||||
const active = current()
|
||||
if (active.kind === "comment") {
|
||||
const view = createMemo(() => {
|
||||
const next = current()
|
||||
if (next.kind !== "comment") return props.renderComment(active.comment)
|
||||
return props.renderComment(next.comment)
|
||||
})
|
||||
return (
|
||||
<Show
|
||||
when={view().editor}
|
||||
fallback={
|
||||
<LineComment
|
||||
inline
|
||||
id={view().id}
|
||||
open={view().open}
|
||||
comment={view().comment}
|
||||
selection={view().selection}
|
||||
actions={view().actions}
|
||||
onClick={view().onClick}
|
||||
onMouseEnter={view().onMouseEnter}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<LineCommentEditor
|
||||
inline
|
||||
id={view().id}
|
||||
value={view().editor!.value}
|
||||
selection={view().editor!.selection}
|
||||
onInput={view().editor!.onInput}
|
||||
onCancel={view().editor!.onCancel}
|
||||
onSubmit={view().editor!.onSubmit}
|
||||
onPopoverFocusOut={view().editor!.onPopoverFocusOut}
|
||||
cancelLabel={view().editor!.cancelLabel}
|
||||
submitLabel={view().editor!.submitLabel}
|
||||
mention={view().editor!.mention}
|
||||
/>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
const view = createMemo(() => {
|
||||
const next = current()
|
||||
if (next.kind !== "draft") return props.renderDraft(active.range)
|
||||
return props.renderDraft(next.range)
|
||||
})
|
||||
return (
|
||||
<LineCommentEditor
|
||||
inline
|
||||
value={view().value}
|
||||
selection={view().selection}
|
||||
onInput={view().onInput}
|
||||
onCancel={view().onCancel}
|
||||
onSubmit={view().onSubmit}
|
||||
onPopoverFocusOut={view().onPopoverFocusOut}
|
||||
mention={view().mention}
|
||||
/>
|
||||
)
|
||||
}, host)
|
||||
|
||||
const node = { host, dispose, setMeta: setCurrent }
|
||||
nodes.set(meta.key, node)
|
||||
return node
|
||||
}
|
||||
|
||||
const render = <A extends { metadata: LineCommentAnnotationMeta<T> }>(annotation: A) => {
|
||||
const meta = annotation.metadata
|
||||
const node = nodes.get(meta.key) ?? mount(meta)
|
||||
if (!node) return
|
||||
node.setMeta(meta)
|
||||
return node.host
|
||||
}
|
||||
|
||||
const reconcile = <A extends { metadata: LineCommentAnnotationMeta<T> }>(annotations: A[]) => {
|
||||
const next = new Set(annotations.map((annotation) => annotation.metadata.key))
|
||||
for (const [key, node] of nodes) {
|
||||
if (next.has(key)) continue
|
||||
node.dispose()
|
||||
nodes.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
const cleanup = () => {
|
||||
for (const [, node] of nodes) node.dispose()
|
||||
nodes.clear()
|
||||
}
|
||||
|
||||
return { render, reconcile, cleanup }
|
||||
}
|
||||
|
||||
export function createLineCommentState<T>(props: LineCommentStateProps<T>) {
|
||||
const [state, setState] = createStore({
|
||||
draft: "",
|
||||
editing: null as T | null,
|
||||
})
|
||||
const draft = () => state.draft
|
||||
const setDraft = (value: string) => setState("draft", value)
|
||||
const editing = () => state.editing
|
||||
const setEditing = (value: T | null) => setState("editing", typeof value === "function" ? () => value : value)
|
||||
|
||||
const toRange = (range: SelectedLineRange | null) => (range ? cloneSelectedLineRange(range) : null)
|
||||
const setSelected = (range: SelectedLineRange | null) => {
|
||||
const next = toRange(range)
|
||||
props.setSelected(next)
|
||||
props.syncSelected?.(toRange(next))
|
||||
return next
|
||||
}
|
||||
|
||||
const setCommenting = (range: SelectedLineRange | null) => {
|
||||
const next = toRange(range)
|
||||
props.setCommenting(next)
|
||||
return next
|
||||
}
|
||||
|
||||
const closeComment = () => {
|
||||
props.setOpened(null)
|
||||
}
|
||||
|
||||
const cancelDraft = () => {
|
||||
setDraft("")
|
||||
setEditing(null)
|
||||
setCommenting(null)
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
setDraft("")
|
||||
setEditing(null)
|
||||
props.setOpened(null)
|
||||
props.setSelected(null)
|
||||
props.setCommenting(null)
|
||||
}
|
||||
|
||||
const openComment = (id: T, range: SelectedLineRange, options?: { cancelDraft?: boolean }) => {
|
||||
if (options?.cancelDraft) cancelDraft()
|
||||
props.setOpened(id)
|
||||
setSelected(range)
|
||||
}
|
||||
|
||||
const toggleComment = (id: T, range: SelectedLineRange, options?: { cancelDraft?: boolean }) => {
|
||||
if (options?.cancelDraft) cancelDraft()
|
||||
const next = props.opened() === id ? null : id
|
||||
props.setOpened(next)
|
||||
setSelected(range)
|
||||
}
|
||||
|
||||
const openDraft = (range: SelectedLineRange) => {
|
||||
const next = toRange(range)
|
||||
setDraft("")
|
||||
setEditing(null)
|
||||
closeComment()
|
||||
setSelected(next)
|
||||
setCommenting(next)
|
||||
}
|
||||
|
||||
const openEditor = (id: T, range: SelectedLineRange, value: string) => {
|
||||
closeComment()
|
||||
setSelected(range)
|
||||
props.setCommenting(null)
|
||||
setEditing(id)
|
||||
setDraft(value)
|
||||
}
|
||||
|
||||
const hoverComment = (range: SelectedLineRange) => {
|
||||
const next = toRange(range)
|
||||
if (!next) return
|
||||
if (props.hoverSelected) {
|
||||
props.hoverSelected(next)
|
||||
return
|
||||
}
|
||||
|
||||
setSelected(next)
|
||||
}
|
||||
|
||||
const finishSelection = (range: SelectedLineRange) => {
|
||||
closeComment()
|
||||
setSelected(range)
|
||||
cancelDraft()
|
||||
}
|
||||
|
||||
return {
|
||||
draft,
|
||||
setDraft,
|
||||
editing,
|
||||
opened: props.opened,
|
||||
selected: props.selected,
|
||||
commenting: props.commenting,
|
||||
isOpen: (id: T) => props.opened() === id,
|
||||
isEditing: (id: T) => editing() === id,
|
||||
closeComment,
|
||||
openComment,
|
||||
toggleComment,
|
||||
openDraft,
|
||||
openEditor,
|
||||
hoverComment,
|
||||
cancelDraft,
|
||||
finishSelection,
|
||||
select: setSelected,
|
||||
reset,
|
||||
}
|
||||
}
|
||||
|
||||
export function createLineCommentController<T extends LineCommentShape>(
|
||||
props: LineCommentControllerWithSideProps<T>,
|
||||
): {
|
||||
note: ReturnType<typeof createLineCommentState<string>>
|
||||
annotations: Accessor<DiffLineAnnotation<LineCommentAnnotationMeta<T>>[]>
|
||||
renderAnnotation: ReturnType<typeof createManagedLineCommentAnnotationRenderer<T>>["renderAnnotation"]
|
||||
renderHoverUtility: ReturnType<typeof createLineCommentHoverRenderer>
|
||||
onLineSelected: (range: SelectedLineRange | null) => void
|
||||
onLineSelectionEnd: (range: SelectedLineRange | null) => void
|
||||
onLineNumberSelectionEnd: (range: SelectedLineRange | null) => void
|
||||
}
|
||||
export function createLineCommentController<T extends LineCommentShape>(
|
||||
props: LineCommentControllerProps<T>,
|
||||
): {
|
||||
note: ReturnType<typeof createLineCommentState<string>>
|
||||
annotations: Accessor<LineCommentAnnotation<T>[]>
|
||||
renderAnnotation: ReturnType<typeof createManagedLineCommentAnnotationRenderer<T>>["renderAnnotation"]
|
||||
renderHoverUtility: ReturnType<typeof createLineCommentHoverRenderer>
|
||||
onLineSelected: (range: SelectedLineRange | null) => void
|
||||
onLineSelectionEnd: (range: SelectedLineRange | null) => void
|
||||
onLineNumberSelectionEnd: (range: SelectedLineRange | null) => void
|
||||
}
|
||||
export function createLineCommentController<T extends LineCommentShape>(
|
||||
props: LineCommentControllerProps<T> | LineCommentControllerWithSideProps<T>,
|
||||
) {
|
||||
const i18n = useI18n()
|
||||
const note = createLineCommentState<string>(props.state)
|
||||
|
||||
const annotations =
|
||||
"getSide" in props
|
||||
? createLineCommentAnnotations({
|
||||
comments: props.comments,
|
||||
getCommentId: (comment) => comment.id,
|
||||
getCommentSelection: (comment) => comment.selection,
|
||||
draftRange: note.commenting,
|
||||
draftKey: props.draftKey,
|
||||
getSide: props.getSide,
|
||||
})
|
||||
: createLineCommentAnnotations({
|
||||
comments: props.comments,
|
||||
getCommentId: (comment) => comment.id,
|
||||
getCommentSelection: (comment) => comment.selection,
|
||||
draftRange: note.commenting,
|
||||
draftKey: props.draftKey,
|
||||
})
|
||||
|
||||
const { renderAnnotation } = createManagedLineCommentAnnotationRenderer<T>({
|
||||
annotations,
|
||||
renderComment: (comment) => {
|
||||
const edit = () => note.openEditor(comment.id, comment.selection, comment.comment)
|
||||
const remove = () => {
|
||||
note.reset()
|
||||
props.onDelete?.(comment)
|
||||
}
|
||||
|
||||
return {
|
||||
id: comment.id,
|
||||
get open() {
|
||||
return note.isOpen(comment.id) || note.isEditing(comment.id)
|
||||
},
|
||||
comment: comment.comment,
|
||||
selection: formatSelectedLineLabel(comment.selection, i18n.t),
|
||||
get actions() {
|
||||
return props.renderCommentActions?.(comment, { edit, remove })
|
||||
},
|
||||
get editor() {
|
||||
return note.isEditing(comment.id)
|
||||
? {
|
||||
get value() {
|
||||
return note.draft()
|
||||
},
|
||||
selection: formatSelectedLineLabel(comment.selection, i18n.t),
|
||||
mention: props.mention,
|
||||
onInput: note.setDraft,
|
||||
onCancel: note.cancelDraft,
|
||||
onSubmit: (value: string) => {
|
||||
props.onUpdate?.({
|
||||
id: comment.id,
|
||||
comment: value,
|
||||
selection: cloneSelectedLineRange(comment.selection),
|
||||
})
|
||||
note.cancelDraft()
|
||||
},
|
||||
submitLabel: props.editSubmitLabel,
|
||||
}
|
||||
: undefined
|
||||
},
|
||||
onMouseEnter: () => note.hoverComment(comment.selection),
|
||||
onClick: () => {
|
||||
if (note.isEditing(comment.id)) return
|
||||
note.toggleComment(comment.id, comment.selection, { cancelDraft: props.cancelDraftOnCommentToggle })
|
||||
},
|
||||
}
|
||||
},
|
||||
renderDraft: (range) => ({
|
||||
get value() {
|
||||
return note.draft()
|
||||
},
|
||||
selection: formatSelectedLineLabel(range, i18n.t),
|
||||
mention: props.mention,
|
||||
onInput: note.setDraft,
|
||||
onCancel: note.cancelDraft,
|
||||
onSubmit: (comment) => {
|
||||
props.onSubmit({ comment, selection: cloneSelectedLineRange(range) })
|
||||
note.cancelDraft()
|
||||
},
|
||||
onPopoverFocusOut: props.onDraftPopoverFocusOut,
|
||||
}),
|
||||
})
|
||||
|
||||
const renderHoverUtility = createLineCommentHoverRenderer({
|
||||
label: props.label,
|
||||
getSelectedRange: () => {
|
||||
if (note.opened()) return null
|
||||
return props.getHoverSelectedRange?.() ?? note.selected()
|
||||
},
|
||||
onOpenDraft: note.openDraft,
|
||||
})
|
||||
|
||||
const onLineSelected = (range: SelectedLineRange | null) => {
|
||||
if (!range) {
|
||||
note.select(null)
|
||||
note.cancelDraft()
|
||||
return
|
||||
}
|
||||
|
||||
note.select(range)
|
||||
}
|
||||
|
||||
const onLineSelectionEnd = (range: SelectedLineRange | null) => {
|
||||
if (!range) {
|
||||
if (props.clearSelectionOnSelectionEndNull) note.select(null)
|
||||
note.cancelDraft()
|
||||
return
|
||||
}
|
||||
|
||||
note.finishSelection(range)
|
||||
}
|
||||
|
||||
const onLineNumberSelectionEnd = (range: SelectedLineRange | null) => {
|
||||
if (!range) return
|
||||
note.openDraft(range)
|
||||
}
|
||||
|
||||
return {
|
||||
note,
|
||||
annotations,
|
||||
renderAnnotation,
|
||||
renderHoverUtility,
|
||||
onLineSelected,
|
||||
onLineSelectionEnd,
|
||||
onLineNumberSelectionEnd,
|
||||
}
|
||||
}
|
||||
|
||||
export function createLineCommentAnnotations<T>(
|
||||
props: LineCommentAnnotationsWithSideProps<T>,
|
||||
): Accessor<DiffLineAnnotation<LineCommentAnnotationMeta<T>>[]>
|
||||
export function createLineCommentAnnotations<T>(
|
||||
props: LineCommentAnnotationsProps<T>,
|
||||
): Accessor<LineCommentAnnotation<T>[]>
|
||||
export function createLineCommentAnnotations<T>(
|
||||
props: LineCommentAnnotationsProps<T> | LineCommentAnnotationsWithSideProps<T>,
|
||||
) {
|
||||
const line = (range: SelectedLineRange) => Math.max(range.start, range.end)
|
||||
|
||||
if ("getSide" in props) {
|
||||
return createMemo<DiffLineAnnotation<LineCommentAnnotationMeta<T>>[]>(() => {
|
||||
const list = props.comments().map((comment) => {
|
||||
const range = props.getCommentSelection(comment)
|
||||
return {
|
||||
side: props.getSide(range),
|
||||
lineNumber: line(range),
|
||||
metadata: {
|
||||
kind: "comment",
|
||||
key: `comment:${props.getCommentId(comment)}`,
|
||||
comment,
|
||||
} satisfies LineCommentAnnotationMeta<T>,
|
||||
}
|
||||
})
|
||||
|
||||
const range = props.draftRange()
|
||||
if (!range) return list
|
||||
|
||||
return [
|
||||
...list,
|
||||
{
|
||||
side: props.getSide(range),
|
||||
lineNumber: line(range),
|
||||
metadata: {
|
||||
kind: "draft",
|
||||
key: `draft:${props.draftKey()}`,
|
||||
range,
|
||||
} satisfies LineCommentAnnotationMeta<T>,
|
||||
},
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
return createMemo<LineCommentAnnotation<T>[]>(() => {
|
||||
const list = props.comments().map((comment) => {
|
||||
const range = props.getCommentSelection(comment)
|
||||
const entry: LineCommentAnnotation<T> = {
|
||||
lineNumber: line(range),
|
||||
metadata: {
|
||||
kind: "comment",
|
||||
key: `comment:${props.getCommentId(comment)}`,
|
||||
comment,
|
||||
},
|
||||
}
|
||||
|
||||
return entry
|
||||
})
|
||||
|
||||
const range = props.draftRange()
|
||||
if (!range) return list
|
||||
|
||||
const draft: LineCommentAnnotation<T> = {
|
||||
lineNumber: line(range),
|
||||
metadata: {
|
||||
kind: "draft",
|
||||
key: `draft:${props.draftKey()}`,
|
||||
range,
|
||||
},
|
||||
}
|
||||
|
||||
return [...list, draft]
|
||||
})
|
||||
}
|
||||
|
||||
export function createManagedLineCommentAnnotationRenderer<T>(props: {
|
||||
annotations: Accessor<LineCommentAnnotation<T>[]>
|
||||
renderComment: (comment: T) => CommentProps
|
||||
renderDraft: (range: SelectedLineRange) => DraftProps
|
||||
}) {
|
||||
const renderer = createLineCommentAnnotationRenderer<T>({
|
||||
renderComment: props.renderComment,
|
||||
renderDraft: props.renderDraft,
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
renderer.reconcile(props.annotations())
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
renderer.cleanup()
|
||||
})
|
||||
|
||||
return {
|
||||
renderAnnotation: renderer.render,
|
||||
}
|
||||
}
|
||||
|
||||
export function createLineCommentHoverRenderer(props: {
|
||||
label: string
|
||||
getSelectedRange: Accessor<SelectedLineRange | null>
|
||||
onOpenDraft: (range: SelectedLineRange) => void
|
||||
}) {
|
||||
return (getHoveredLine: () => HoverCommentLine | undefined) =>
|
||||
createHoverCommentUtility({
|
||||
label: props.label,
|
||||
getHoveredLine,
|
||||
onSelect: (hovered) => {
|
||||
const current = props.getSelectedRange()
|
||||
if (current && lineInSelectedRange(current, hovered.lineNumber, hovered.side)) {
|
||||
props.onOpenDraft(cloneSelectedLineRange(current))
|
||||
return
|
||||
}
|
||||
|
||||
const range: SelectedLineRange = {
|
||||
start: hovered.lineNumber,
|
||||
end: hovered.lineNumber,
|
||||
}
|
||||
if (hovered.side) range.side = hovered.side
|
||||
props.onOpenDraft(range)
|
||||
},
|
||||
})
|
||||
}
|
||||
292
qimingcode/packages/ui/src/components/line-comment-styles.ts
Normal file
292
qimingcode/packages/ui/src/components/line-comment-styles.ts
Normal file
@@ -0,0 +1,292 @@
|
||||
export const lineCommentStyles = `
|
||||
[data-annotation-slot] {
|
||||
padding: 12px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
[data-component="line-comment"] {
|
||||
position: absolute;
|
||||
right: 24px;
|
||||
z-index: var(--line-comment-z, 30);
|
||||
}
|
||||
|
||||
[data-component="line-comment"][data-inline] {
|
||||
position: relative;
|
||||
right: auto;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
[data-component="line-comment"][data-open] {
|
||||
z-index: var(--line-comment-open-z, 100);
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-slot="line-comment-button"] {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: var(--radius-md);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--icon-interactive-base);
|
||||
box-shadow: var(--shadow-xs);
|
||||
cursor: default;
|
||||
border: none;
|
||||
}
|
||||
|
||||
[data-component="line-comment"][data-variant="add"] [data-slot="line-comment-button"] {
|
||||
background: var(--syntax-diff-add);
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-component="icon"] {
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-slot="line-comment-icon"] {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-slot="line-comment-button"]:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-slot="line-comment-button"]:focus-visible {
|
||||
box-shadow: var(--shadow-xs-border-focus);
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-slot="line-comment-popover"] {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
right: -8px;
|
||||
z-index: var(--line-comment-popover-z, 40);
|
||||
min-width: 200px;
|
||||
max-width: none;
|
||||
box-sizing: border-box;
|
||||
border-radius: 8px;
|
||||
background: var(--surface-raised-stronger-non-alpha);
|
||||
box-shadow: var(--shadow-xxs-border);
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
[data-component="line-comment"][data-inline] [data-slot="line-comment-popover"] {
|
||||
position: relative;
|
||||
top: auto;
|
||||
right: auto;
|
||||
margin-left: 8px;
|
||||
flex: 1 1 0%;
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
[data-component="line-comment"][data-inline] [data-slot="line-comment-popover"][data-inline-body] {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
[data-component="line-comment"][data-inline][data-variant="default"] [data-slot="line-comment-popover"][data-inline-body] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
[data-component="line-comment"][data-variant="editor"] [data-slot="line-comment-popover"] {
|
||||
width: 380px;
|
||||
max-width: none;
|
||||
padding: 8px;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
[data-component="line-comment"][data-inline][data-variant="editor"] [data-slot="line-comment-popover"] {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-slot="line-comment-content"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-slot="line-comment-head"] {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-slot="line-comment-text"] {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: var(--font-weight-regular);
|
||||
line-height: var(--line-height-x-large);
|
||||
letter-spacing: var(--letter-spacing-normal);
|
||||
color: var(--text-strong);
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-slot="line-comment-tools"] {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-slot="line-comment-label"],
|
||||
[data-component="line-comment"] [data-slot="line-comment-editor-label"] {
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-small);
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: var(--line-height-large);
|
||||
letter-spacing: var(--letter-spacing-normal);
|
||||
color: var(--text-weak);
|
||||
min-width: 0;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-slot="line-comment-editor"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-slot="line-comment-textarea"] {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
resize: vertical;
|
||||
padding: 8px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface-base);
|
||||
border: 1px solid var(--border-base);
|
||||
color: var(--text-strong);
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-small);
|
||||
line-height: var(--line-height-large);
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-slot="line-comment-textarea"]:focus {
|
||||
outline: none;
|
||||
box-shadow: var(--shadow-xs-border-select);
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-slot="line-comment-mention-list"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
max-height: 180px;
|
||||
overflow: auto;
|
||||
padding: 4px;
|
||||
border: 1px solid var(--border-base);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface-base);
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-slot="line-comment-mention-item"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 6px 8px;
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--text-strong);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-slot="line-comment-mention-item"][data-active] {
|
||||
background: var(--surface-raised-base-hover);
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-slot="line-comment-mention-path"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-small);
|
||||
line-height: var(--line-height-large);
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-slot="line-comment-mention-dir"] {
|
||||
min-width: 0;
|
||||
color: var(--text-weak);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-slot="line-comment-mention-file"] {
|
||||
color: var(--text-strong);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-slot="line-comment-actions"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding-left: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-slot="line-comment-editor-label"] {
|
||||
flex: 1 1 220px;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-slot="line-comment-action"] {
|
||||
border: 1px solid var(--border-base);
|
||||
background: var(--surface-base);
|
||||
color: var(--text-strong);
|
||||
border-radius: var(--radius-md);
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-small);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-slot="line-comment-action"][data-variant="ghost"] {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-slot="line-comment-action"][data-variant="primary"] {
|
||||
background: var(--text-strong);
|
||||
border-color: var(--text-strong);
|
||||
color: var(--background-base);
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-slot="line-comment-action"]:disabled {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
`
|
||||
|
||||
let installed = false
|
||||
|
||||
export function installLineCommentStyles() {
|
||||
if (installed) return
|
||||
if (typeof document === "undefined") return
|
||||
|
||||
const id = "opencode-line-comment-styles"
|
||||
if (document.getElementById(id)) {
|
||||
installed = true
|
||||
return
|
||||
}
|
||||
|
||||
const style = document.createElement("style")
|
||||
style.id = id
|
||||
style.textContent = lineCommentStyles
|
||||
document.head.appendChild(style)
|
||||
installed = true
|
||||
}
|
||||
115
qimingcode/packages/ui/src/components/line-comment.stories.tsx
Normal file
115
qimingcode/packages/ui/src/components/line-comment.stories.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
// @ts-nocheck
|
||||
import { createSignal } from "solid-js"
|
||||
import * as mod from "./line-comment"
|
||||
|
||||
const docs = `### Overview
|
||||
Inline comment anchor and editor for code review or annotation flows.
|
||||
|
||||
Pair with \`Diff\` or \`Code\` to align comments to lines.
|
||||
|
||||
### API
|
||||
- \`LineCommentAnchor\`: position with \`top\`, control \`open\`, render custom children.
|
||||
- \`LineComment\`: convenience wrapper for displaying comment + selection label.
|
||||
- \`LineCommentEditor\`: controlled textarea with submit/cancel handlers.
|
||||
|
||||
### Variants and states
|
||||
- Default display and editor display variants.
|
||||
|
||||
### Behavior
|
||||
- Anchor positions relative to a containing element.
|
||||
- Editor submits on Enter (Shift+Enter for newline).
|
||||
|
||||
### Accessibility
|
||||
- TODO: confirm ARIA labeling for comment button and editor textarea.
|
||||
|
||||
### Theming/tokens
|
||||
- Uses \`data-component="line-comment"\` and related slots.
|
||||
|
||||
`
|
||||
|
||||
export default {
|
||||
title: "UI/LineComment",
|
||||
id: "components-line-comment",
|
||||
component: mod.LineComment,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: docs,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const Default = {
|
||||
render: () => (
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
height: "160px",
|
||||
padding: "16px 16px 16px 40px",
|
||||
border: "1px solid var(--border-weak)",
|
||||
"border-radius": "8px",
|
||||
"font-family": "var(--font-family-mono)",
|
||||
"font-size": "12px",
|
||||
color: "var(--text-weak)",
|
||||
}}
|
||||
>
|
||||
<div>12 | const total = sum(values)</div>
|
||||
<div>13 | return total / values.length</div>
|
||||
<mod.LineComment open top={18} comment="Consider guarding against empty arrays." selection="L12-L13" />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const Editor = {
|
||||
render: () => {
|
||||
const [value, setValue] = createSignal("Add context for this change.")
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
height: "220px",
|
||||
padding: "16px 16px 16px 40px",
|
||||
border: "1px solid var(--border-weak)",
|
||||
"border-radius": "8px",
|
||||
"font-family": "var(--font-family-mono)",
|
||||
"font-size": "12px",
|
||||
color: "var(--text-weak)",
|
||||
}}
|
||||
>
|
||||
<div>40 | if (values.length === 0) return 0</div>
|
||||
<mod.LineCommentEditor
|
||||
top={24}
|
||||
value={value()}
|
||||
selection="L40"
|
||||
onInput={setValue}
|
||||
onCancel={() => setValue("")}
|
||||
onSubmit={(next) => setValue(next)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
export const AnchorOnly = {
|
||||
render: () => (
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
height: "120px",
|
||||
padding: "16px 16px 16px 40px",
|
||||
border: "1px solid var(--border-weak)",
|
||||
"border-radius": "8px",
|
||||
"font-family": "var(--font-family-mono)",
|
||||
"font-size": "12px",
|
||||
color: "var(--text-weak)",
|
||||
}}
|
||||
>
|
||||
<div>20 | const ready = true</div>
|
||||
<mod.LineCommentAnchor top={18} open={false}>
|
||||
<div data-slot="line-comment-content">Anchor content</div>
|
||||
</mod.LineCommentAnchor>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
438
qimingcode/packages/ui/src/components/line-comment.tsx
Normal file
438
qimingcode/packages/ui/src/components/line-comment.tsx
Normal file
@@ -0,0 +1,438 @@
|
||||
import { useFilteredList } from "@opencode-ai/ui/hooks"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
|
||||
import { createSignal, For, onMount, Show, splitProps, type JSX } from "solid-js"
|
||||
import { Button } from "./button"
|
||||
import { FileIcon } from "./file-icon"
|
||||
import { Icon } from "./icon"
|
||||
import { installLineCommentStyles } from "./line-comment-styles"
|
||||
import { useI18n } from "../context/i18n"
|
||||
|
||||
installLineCommentStyles()
|
||||
|
||||
export type LineCommentVariant = "default" | "editor" | "add"
|
||||
|
||||
function InlineGlyph(props: { icon: "comment" | "plus" }) {
|
||||
return (
|
||||
<svg data-slot="line-comment-icon" viewBox="0 0 20 20" fill="none" aria-hidden="true">
|
||||
<Show
|
||||
when={props.icon === "comment"}
|
||||
fallback={
|
||||
<path
|
||||
d="M10 5.41699V10.0003M10 10.0003V14.5837M10 10.0003H5.4165M10 10.0003H14.5832"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="square"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<path d="M16.25 3.75H3.75V16.25L6.875 14.4643H16.25V3.75Z" stroke="currentColor" stroke-linecap="square" />
|
||||
</Show>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export type LineCommentAnchorProps = {
|
||||
id?: string
|
||||
top?: number
|
||||
inline?: boolean
|
||||
hideButton?: boolean
|
||||
open: boolean
|
||||
variant?: LineCommentVariant
|
||||
icon?: "comment" | "plus"
|
||||
buttonLabel?: string
|
||||
onClick?: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent>
|
||||
onMouseEnter?: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent>
|
||||
onPopoverFocusOut?: JSX.EventHandlerUnion<HTMLDivElement, FocusEvent>
|
||||
class?: string
|
||||
popoverClass?: string
|
||||
children?: JSX.Element
|
||||
}
|
||||
|
||||
export const LineCommentAnchor = (props: LineCommentAnchorProps) => {
|
||||
const hidden = () => !props.inline && props.top === undefined
|
||||
const variant = () => props.variant ?? "default"
|
||||
const icon = () => props.icon ?? "comment"
|
||||
const inlineBody = () => props.inline && props.hideButton
|
||||
|
||||
return (
|
||||
<div
|
||||
data-component="line-comment"
|
||||
data-prevent-autofocus=""
|
||||
data-variant={variant()}
|
||||
data-comment-id={props.id}
|
||||
data-open={props.open ? "" : undefined}
|
||||
data-inline={props.inline ? "" : undefined}
|
||||
classList={{
|
||||
[props.class ?? ""]: !!props.class,
|
||||
}}
|
||||
style={
|
||||
props.inline
|
||||
? undefined
|
||||
: {
|
||||
top: `${props.top ?? 0}px`,
|
||||
opacity: hidden() ? 0 : 1,
|
||||
"pointer-events": hidden() ? "none" : "auto",
|
||||
}
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={inlineBody()}
|
||||
fallback={
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={props.buttonLabel}
|
||||
data-slot="line-comment-button"
|
||||
on:mousedown={(e) => e.stopPropagation()}
|
||||
on:mouseup={(e) => e.stopPropagation()}
|
||||
on:click={props.onClick as any}
|
||||
on:mouseenter={props.onMouseEnter as any}
|
||||
>
|
||||
<Show
|
||||
when={props.inline}
|
||||
fallback={<Icon name={icon() === "plus" ? "plus-small" : "comment"} size="small" />}
|
||||
>
|
||||
<InlineGlyph icon={icon()} />
|
||||
</Show>
|
||||
</button>
|
||||
<Show when={props.open}>
|
||||
<div
|
||||
data-slot="line-comment-popover"
|
||||
classList={{
|
||||
[props.popoverClass ?? ""]: !!props.popoverClass,
|
||||
}}
|
||||
on:mousedown={(e) => e.stopPropagation()}
|
||||
on:focusout={props.onPopoverFocusOut as any}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
</Show>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div
|
||||
data-slot="line-comment-popover"
|
||||
data-inline-body=""
|
||||
classList={{
|
||||
[props.popoverClass ?? ""]: !!props.popoverClass,
|
||||
}}
|
||||
on:mousedown={(e) => e.stopPropagation()}
|
||||
on:click={props.onClick as any}
|
||||
on:mouseenter={props.onMouseEnter as any}
|
||||
on:focusout={props.onPopoverFocusOut as any}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export type LineCommentProps = Omit<LineCommentAnchorProps, "children" | "variant"> & {
|
||||
comment: JSX.Element
|
||||
selection: JSX.Element
|
||||
actions?: JSX.Element
|
||||
}
|
||||
|
||||
export const LineComment = (props: LineCommentProps) => {
|
||||
const i18n = useI18n()
|
||||
const [split, rest] = splitProps(props, ["comment", "selection", "actions"])
|
||||
|
||||
return (
|
||||
<LineCommentAnchor {...rest} variant="default" hideButton={props.inline}>
|
||||
<div data-slot="line-comment-content">
|
||||
<div data-slot="line-comment-head">
|
||||
<div data-slot="line-comment-text">{split.comment}</div>
|
||||
<Show when={split.actions}>
|
||||
<div data-slot="line-comment-tools">{split.actions}</div>
|
||||
</Show>
|
||||
</div>
|
||||
<div data-slot="line-comment-label">
|
||||
{i18n.t("ui.lineComment.label.prefix")}
|
||||
{split.selection}
|
||||
{i18n.t("ui.lineComment.label.suffix")}
|
||||
</div>
|
||||
</div>
|
||||
</LineCommentAnchor>
|
||||
)
|
||||
}
|
||||
|
||||
export type LineCommentAddProps = Omit<LineCommentAnchorProps, "children" | "variant" | "open" | "icon"> & {
|
||||
label?: string
|
||||
}
|
||||
|
||||
export const LineCommentAdd = (props: LineCommentAddProps) => {
|
||||
const [split, rest] = splitProps(props, ["label"])
|
||||
const i18n = useI18n()
|
||||
|
||||
return (
|
||||
<LineCommentAnchor
|
||||
{...rest}
|
||||
open={false}
|
||||
variant="add"
|
||||
icon="plus"
|
||||
buttonLabel={split.label ?? i18n.t("ui.lineComment.submit")}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export type LineCommentEditorProps = Omit<LineCommentAnchorProps, "children" | "open" | "variant" | "onClick"> & {
|
||||
value: string
|
||||
selection: JSX.Element
|
||||
onInput: (value: string) => void
|
||||
onCancel: VoidFunction
|
||||
onSubmit: (value: string) => void
|
||||
placeholder?: string
|
||||
rows?: number
|
||||
autofocus?: boolean
|
||||
cancelLabel?: string
|
||||
submitLabel?: string
|
||||
mention?: {
|
||||
items: (query: string) => string[] | Promise<string[]>
|
||||
}
|
||||
}
|
||||
|
||||
export const LineCommentEditor = (props: LineCommentEditorProps) => {
|
||||
const i18n = useI18n()
|
||||
const [split, rest] = splitProps(props, [
|
||||
"value",
|
||||
"selection",
|
||||
"onInput",
|
||||
"onCancel",
|
||||
"onSubmit",
|
||||
"placeholder",
|
||||
"rows",
|
||||
"autofocus",
|
||||
"cancelLabel",
|
||||
"submitLabel",
|
||||
"mention",
|
||||
])
|
||||
|
||||
const refs = {
|
||||
textarea: undefined as HTMLTextAreaElement | undefined,
|
||||
}
|
||||
const [open, setOpen] = createSignal(false)
|
||||
|
||||
function selectMention(item: { path: string } | undefined) {
|
||||
if (!item) return
|
||||
|
||||
const textarea = refs.textarea
|
||||
const query = currentMention()
|
||||
if (!textarea || !query) return
|
||||
|
||||
const value = `${textarea.value.slice(0, query.start)}@${item.path} ${textarea.value.slice(query.end)}`
|
||||
const cursor = query.start + item.path.length + 2
|
||||
|
||||
split.onInput(value)
|
||||
closeMention()
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
textarea.focus()
|
||||
textarea.setSelectionRange(cursor, cursor)
|
||||
})
|
||||
}
|
||||
|
||||
const mention = useFilteredList<{ path: string }>({
|
||||
items: async (query) => {
|
||||
if (!split.mention) return []
|
||||
if (!query.trim()) return []
|
||||
const paths = await split.mention.items(query)
|
||||
return paths.map((path) => ({ path }))
|
||||
},
|
||||
key: (item) => item.path,
|
||||
filterKeys: ["path"],
|
||||
onSelect: selectMention,
|
||||
})
|
||||
|
||||
const focus = () => refs.textarea?.focus()
|
||||
const hold: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent> = (e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}
|
||||
const click =
|
||||
(fn: VoidFunction): JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent> =>
|
||||
(e) => {
|
||||
e.stopPropagation()
|
||||
fn()
|
||||
}
|
||||
|
||||
const closeMention = () => {
|
||||
setOpen(false)
|
||||
mention.clear()
|
||||
}
|
||||
|
||||
const currentMention = () => {
|
||||
const textarea = refs.textarea
|
||||
if (!textarea) return
|
||||
if (!split.mention) return
|
||||
if (textarea.selectionStart !== textarea.selectionEnd) return
|
||||
|
||||
const end = textarea.selectionStart
|
||||
const match = textarea.value.slice(0, end).match(/@(\S*)$/)
|
||||
if (!match) return
|
||||
|
||||
return {
|
||||
query: match[1] ?? "",
|
||||
start: end - match[0].length,
|
||||
end,
|
||||
}
|
||||
}
|
||||
|
||||
const syncMention = () => {
|
||||
const item = currentMention()
|
||||
if (!item) {
|
||||
closeMention()
|
||||
return
|
||||
}
|
||||
|
||||
setOpen(true)
|
||||
mention.onInput(item.query)
|
||||
}
|
||||
|
||||
const selectActiveMention = () => {
|
||||
const items = mention.flat()
|
||||
if (items.length === 0) return
|
||||
const active = mention.active()
|
||||
selectMention(items.find((item) => item.path === active) ?? items[0])
|
||||
}
|
||||
|
||||
const submit = () => {
|
||||
const value = split.value.trim()
|
||||
if (!value) return
|
||||
split.onSubmit(value)
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (split.autofocus === false) return
|
||||
requestAnimationFrame(focus)
|
||||
})
|
||||
|
||||
return (
|
||||
<LineCommentAnchor {...rest} open={true} variant="editor" hideButton={props.inline} onClick={() => focus()}>
|
||||
<div data-slot="line-comment-editor">
|
||||
<textarea
|
||||
ref={(el) => {
|
||||
refs.textarea = el
|
||||
}}
|
||||
data-slot="line-comment-textarea"
|
||||
rows={split.rows ?? 3}
|
||||
placeholder={split.placeholder ?? i18n.t("ui.lineComment.placeholder")}
|
||||
value={split.value}
|
||||
on:input={(e) => {
|
||||
const value = (e.currentTarget as HTMLTextAreaElement).value
|
||||
split.onInput(value)
|
||||
syncMention()
|
||||
}}
|
||||
on:click={() => syncMention()}
|
||||
on:select={() => syncMention()}
|
||||
on:keydown={(e) => {
|
||||
const event = e as KeyboardEvent
|
||||
if (event.isComposing || event.keyCode === 229) return
|
||||
event.stopPropagation()
|
||||
if (open()) {
|
||||
if (e.key === "Escape") {
|
||||
event.preventDefault()
|
||||
closeMention()
|
||||
return
|
||||
}
|
||||
|
||||
if (e.key === "Tab") {
|
||||
if (mention.flat().length === 0) return
|
||||
event.preventDefault()
|
||||
selectActiveMention()
|
||||
return
|
||||
}
|
||||
|
||||
const nav = e.key === "ArrowUp" || e.key === "ArrowDown" || e.key === "Enter"
|
||||
const ctrlNav =
|
||||
event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey && (e.key === "n" || e.key === "p")
|
||||
if ((nav || ctrlNav) && mention.flat().length > 0) {
|
||||
mention.onKeyDown(event)
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key === "Escape") {
|
||||
event.preventDefault()
|
||||
e.currentTarget.blur()
|
||||
split.onCancel()
|
||||
return
|
||||
}
|
||||
if (e.key !== "Enter") return
|
||||
if (e.shiftKey) return
|
||||
event.preventDefault()
|
||||
submit()
|
||||
}}
|
||||
/>
|
||||
<Show when={open() && mention.flat().length > 0}>
|
||||
<div data-slot="line-comment-mention-list">
|
||||
<For each={mention.flat().slice(0, 10)}>
|
||||
{(item) => {
|
||||
const directory = item.path.endsWith("/") ? item.path : getDirectory(item.path)
|
||||
const name = item.path.endsWith("/") ? "" : getFilename(item.path)
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-slot="line-comment-mention-item"
|
||||
data-active={mention.active() === item.path ? "" : undefined}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onMouseEnter={() => mention.setActive(item.path)}
|
||||
onClick={() => selectMention(item)}
|
||||
>
|
||||
<FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-4" />
|
||||
<div data-slot="line-comment-mention-path">
|
||||
<span data-slot="line-comment-mention-dir">{directory}</span>
|
||||
<Show when={name}>
|
||||
<span data-slot="line-comment-mention-file">{name}</span>
|
||||
</Show>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
<div data-slot="line-comment-actions">
|
||||
<div data-slot="line-comment-editor-label">
|
||||
{i18n.t("ui.lineComment.editorLabel.prefix")}
|
||||
{split.selection}
|
||||
{i18n.t("ui.lineComment.editorLabel.suffix")}
|
||||
</div>
|
||||
<Show
|
||||
when={!props.inline}
|
||||
fallback={
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
data-slot="line-comment-action"
|
||||
data-variant="ghost"
|
||||
on:mousedown={hold as any}
|
||||
on:click={click(split.onCancel) as any}
|
||||
>
|
||||
{split.cancelLabel ?? i18n.t("ui.common.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-slot="line-comment-action"
|
||||
data-variant="primary"
|
||||
disabled={split.value.trim().length === 0}
|
||||
on:mousedown={hold as any}
|
||||
on:click={click(submit) as any}
|
||||
>
|
||||
{split.submitLabel ?? i18n.t("ui.lineComment.submit")}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Button size="small" variant="ghost" onClick={split.onCancel}>
|
||||
{split.cancelLabel ?? i18n.t("ui.common.cancel")}
|
||||
</Button>
|
||||
<Button size="small" variant="primary" disabled={split.value.trim().length === 0} onClick={submit}>
|
||||
{split.submitLabel ?? i18n.t("ui.lineComment.submit")}
|
||||
</Button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</LineCommentAnchor>
|
||||
)
|
||||
}
|
||||
331
qimingcode/packages/ui/src/components/list.css
Normal file
331
qimingcode/packages/ui/src/components/list.css
Normal file
@@ -0,0 +1,331 @@
|
||||
@property --bottom-fade {
|
||||
syntax: "<length>";
|
||||
inherits: false;
|
||||
initial-value: 0px;
|
||||
}
|
||||
|
||||
@keyframes scroll {
|
||||
0% {
|
||||
--bottom-fade: 20px;
|
||||
}
|
||||
90% {
|
||||
--bottom-fade: 20px;
|
||||
}
|
||||
100% {
|
||||
--bottom-fade: 0;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="list"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
overflow: hidden;
|
||||
padding: 0 12px;
|
||||
|
||||
[data-slot="list-search-wrapper"] {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
align-self: stretch;
|
||||
margin-bottom: 4px;
|
||||
|
||||
> [data-component="icon-button"] {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
background-color: transparent;
|
||||
opacity: 0.5;
|
||||
transition: opacity 0.15s ease;
|
||||
|
||||
&:hover:not(:disabled),
|
||||
&:focus-visible:not(:disabled),
|
||||
&:active:not(:disabled) {
|
||||
background-color: transparent;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) [data-slot="icon-svg"] {
|
||||
color: var(--icon-hover);
|
||||
}
|
||||
|
||||
&:active:not(:disabled) [data-slot="icon-svg"] {
|
||||
color: var(--icon-active);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="list-search"] {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
padding: 8px;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface-base);
|
||||
|
||||
[data-slot="list-search-container"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1 0 0;
|
||||
max-height: 20px;
|
||||
|
||||
[data-slot="list-search-input"] {
|
||||
width: 100%;
|
||||
|
||||
&[data-slot="input-input"] {
|
||||
line-height: 20px;
|
||||
max-height: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> [data-component="icon-button"] {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background-color: transparent;
|
||||
opacity: 0.5;
|
||||
transition: opacity 0.15s ease;
|
||||
|
||||
&:hover:not(:disabled),
|
||||
&:focus-visible:not(:disabled),
|
||||
&:active:not(:disabled) {
|
||||
background-color: transparent;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) [data-slot="icon-svg"] {
|
||||
color: var(--icon-hover);
|
||||
}
|
||||
|
||||
&:active:not(:disabled) [data-slot="icon-svg"] {
|
||||
color: var(--icon-active);
|
||||
}
|
||||
}
|
||||
|
||||
> [data-component="icon-button"] {
|
||||
background-color: transparent;
|
||||
|
||||
&:hover:not(:disabled),
|
||||
&:focus:not(:disabled),
|
||||
&:active:not(:disabled) {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) [data-slot="icon-svg"] {
|
||||
color: var(--icon-hover);
|
||||
}
|
||||
|
||||
&:active:not(:disabled) [data-slot="icon-svg"] {
|
||||
color: var(--icon-active);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="list-scroll"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
mask: linear-gradient(to bottom, #ffff calc(100% - var(--bottom-fade)), #0000);
|
||||
animation: scroll;
|
||||
animation-timeline: --scroll;
|
||||
scroll-timeline: --scroll y;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-slot="list-empty-state"] {
|
||||
display: flex;
|
||||
padding: 32px 48px;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
align-self: stretch;
|
||||
|
||||
[data-slot="list-message"] {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
max-width: 100%;
|
||||
color: var(--text-weak);
|
||||
white-space: nowrap;
|
||||
|
||||
/* text-14-regular */
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-regular);
|
||||
line-height: var(--line-height-large); /* 142.857% */
|
||||
letter-spacing: var(--letter-spacing-normal);
|
||||
}
|
||||
|
||||
[data-slot="list-filter"] {
|
||||
color: var(--text-strong);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="list-group"] {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&:last-child {
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
[data-slot="list-header"] {
|
||||
display: flex;
|
||||
z-index: 10;
|
||||
padding: 8px 12px 8px 8px;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
align-self: stretch;
|
||||
background: var(--surface-raised-stronger-non-alpha);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
|
||||
color: var(--text-weak);
|
||||
|
||||
/* text-14-medium */
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: var(--line-height-large); /* 142.857% */
|
||||
letter-spacing: var(--letter-spacing-normal);
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 16px;
|
||||
background: linear-gradient(to bottom, var(--surface-raised-stronger-non-alpha), transparent);
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
&[data-stuck="true"]::after {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="list-items"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
align-self: stretch;
|
||||
|
||||
[data-slot="list-item"] {
|
||||
display: flex;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
padding: 6px 8px 6px 8px;
|
||||
align-items: center;
|
||||
color: var(--text-strong);
|
||||
scroll-margin-top: 28px;
|
||||
|
||||
/* text-14-medium */
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: var(--line-height-large); /* 142.857% */
|
||||
letter-spacing: var(--letter-spacing-normal);
|
||||
|
||||
[data-slot="list-item-selected-icon"] {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
aspect-ratio: 1/1;
|
||||
[data-component="icon"] {
|
||||
color: var(--icon-strong-base);
|
||||
}
|
||||
}
|
||||
[data-slot="list-item-active-icon"] {
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
aspect-ratio: 1/1;
|
||||
[data-component="icon"] {
|
||||
color: var(--icon-strong-base);
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="list-item-extra-icon"] {
|
||||
color: var(--icon-base);
|
||||
margin-left: -4px;
|
||||
}
|
||||
|
||||
[data-slot="list-item-divider"] {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: var(--list-divider-inset, 16px);
|
||||
right: var(--list-divider-inset, 16px);
|
||||
height: 1px;
|
||||
background: var(--border-weak-base);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
[data-slot="list-item"]:last-child [data-slot="list-item-divider"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&[data-active="true"] {
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface-raised-base-hover);
|
||||
[data-slot="list-item-active-icon"] {
|
||||
display: inline-flex;
|
||||
}
|
||||
[data-slot="list-item-extra-icon"] {
|
||||
display: block !important;
|
||||
color: var(--icon-strong-base) !important;
|
||||
}
|
||||
}
|
||||
&:active {
|
||||
background: var(--surface-raised-base-active);
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="list-item-add"] {
|
||||
display: flex;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
padding: 6px 8px 6px 8px;
|
||||
align-items: center;
|
||||
color: var(--text-strong);
|
||||
|
||||
/* text-14-medium */
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: var(--line-height-large); /* 142.857% */
|
||||
letter-spacing: var(--letter-spacing-normal);
|
||||
|
||||
[data-component="input"] {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
170
qimingcode/packages/ui/src/components/list.stories.tsx
Normal file
170
qimingcode/packages/ui/src/components/list.stories.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
// @ts-nocheck
|
||||
import * as mod from "./list"
|
||||
import { create } from "../storybook/scaffold"
|
||||
|
||||
const docs = `### Overview
|
||||
Filterable list with keyboard navigation and optional search input.
|
||||
|
||||
Use within panels or popovers where keyboard navigation is expected.
|
||||
|
||||
### API
|
||||
- Required: \`items\` and \`key\`.
|
||||
- Required: \`children\` render function for items.
|
||||
- Optional: \`search\`, \`filterKeys\`, \`groupBy\`, \`onSelect\`, \`onKeyEvent\`.
|
||||
|
||||
### Variants and states
|
||||
- Optional search bar and group headers.
|
||||
|
||||
### Behavior
|
||||
- Uses fuzzy search when \`search\` is enabled.
|
||||
- Keyboard navigation via arrow keys; Enter selects.
|
||||
|
||||
### Accessibility
|
||||
- TODO: confirm ARIA roles for list items and search input.
|
||||
|
||||
### Theming/tokens
|
||||
- Uses \`data-component="list"\` and data slots for structure.
|
||||
|
||||
`
|
||||
|
||||
const story = create({
|
||||
title: "UI/List",
|
||||
mod,
|
||||
args: {
|
||||
items: ["One", "Two", "Three", "Four"],
|
||||
key: (x: string) => x,
|
||||
children: (x: string) => x,
|
||||
search: true,
|
||||
},
|
||||
})
|
||||
|
||||
export default {
|
||||
title: "UI/List",
|
||||
id: "components-list",
|
||||
component: story.meta.component,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: docs,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const Basic = story.Basic
|
||||
|
||||
export const Grouped = {
|
||||
render: () => {
|
||||
const items = [
|
||||
{ id: "a1", title: "Alpha", group: "Group A" },
|
||||
{ id: "a2", title: "Bravo", group: "Group A" },
|
||||
{ id: "b1", title: "Delta", group: "Group B" },
|
||||
]
|
||||
return (
|
||||
<mod.List items={items} key={(item) => item.id} groupBy={(item) => item.group} search={true}>
|
||||
{(item) => item.title}
|
||||
</mod.List>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
export const Empty = {
|
||||
render: () => (
|
||||
<mod.List items={[]} key={(item) => item} search={true}>
|
||||
{(item) => item}
|
||||
</mod.List>
|
||||
),
|
||||
}
|
||||
|
||||
export const WithAdd = {
|
||||
render: () => (
|
||||
<mod.List
|
||||
items={["One", "Two"]}
|
||||
key={(item) => item}
|
||||
search={true}
|
||||
add={{
|
||||
render: () => (
|
||||
<button type="button" data-slot="list-item">
|
||||
Add item
|
||||
</button>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{(item) => item}
|
||||
</mod.List>
|
||||
),
|
||||
}
|
||||
|
||||
export const Divider = {
|
||||
render: () => (
|
||||
<mod.List items={["One", "Two", "Three"]} key={(item) => item} divider={true}>
|
||||
{(item) => item}
|
||||
</mod.List>
|
||||
),
|
||||
}
|
||||
|
||||
export const ActiveIcon = {
|
||||
render: () => (
|
||||
<mod.List items={["Alpha", "Beta", "Gamma"]} key={(item) => item} activeIcon="chevron-right">
|
||||
{(item) => item}
|
||||
</mod.List>
|
||||
),
|
||||
}
|
||||
|
||||
export const NoSearch = {
|
||||
render: () => (
|
||||
<mod.List items={["One", "Two", "Three"]} key={(item) => item} search={false}>
|
||||
{(item) => item}
|
||||
</mod.List>
|
||||
),
|
||||
}
|
||||
|
||||
export const SearchOptions = {
|
||||
render: () => (
|
||||
<mod.List
|
||||
items={["Apple", "Banana", "Cherry"]}
|
||||
key={(item) => item}
|
||||
search={{
|
||||
placeholder: "Filter...",
|
||||
hideIcon: true,
|
||||
action: <button type="button">Action</button>,
|
||||
}}
|
||||
>
|
||||
{(item) => item}
|
||||
</mod.List>
|
||||
),
|
||||
}
|
||||
|
||||
export const ItemWrapper = {
|
||||
render: () => (
|
||||
<mod.List
|
||||
items={["One", "Two", "Three"]}
|
||||
key={(item) => item}
|
||||
itemWrapper={(item, node) => (
|
||||
<div style={{ border: "1px solid var(--border-weak)", "border-radius": "6px", margin: "4px 0" }}>{node}</div>
|
||||
)}
|
||||
>
|
||||
{(item) => item}
|
||||
</mod.List>
|
||||
),
|
||||
}
|
||||
|
||||
export const GroupHeader = {
|
||||
render: () => {
|
||||
const items = [
|
||||
{ id: "a1", title: "Alpha", group: "Group A" },
|
||||
{ id: "b1", title: "Beta", group: "Group B" },
|
||||
]
|
||||
return (
|
||||
<mod.List
|
||||
items={items}
|
||||
key={(item) => item.id}
|
||||
groupBy={(item) => item.group}
|
||||
groupHeader={(group) => <strong>{group.category}</strong>}
|
||||
>
|
||||
{(item) => item.title}
|
||||
</mod.List>
|
||||
)
|
||||
},
|
||||
}
|
||||
394
qimingcode/packages/ui/src/components/list.tsx
Normal file
394
qimingcode/packages/ui/src/components/list.tsx
Normal file
@@ -0,0 +1,394 @@
|
||||
import { type FilteredListProps, useFilteredList } from "@opencode-ai/ui/hooks"
|
||||
import { createEffect, For, type JSX, on, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { useI18n } from "../context/i18n"
|
||||
import { Icon, type IconProps } from "./icon"
|
||||
import { IconButton } from "./icon-button"
|
||||
import { TextField } from "./text-field"
|
||||
|
||||
function findByKey(container: HTMLElement, key: string) {
|
||||
const nodes = container.querySelectorAll<HTMLElement>('[data-slot="list-item"][data-key]')
|
||||
for (const node of nodes) {
|
||||
if (node.getAttribute("data-key") === key) return node
|
||||
}
|
||||
}
|
||||
|
||||
export interface ListSearchProps {
|
||||
placeholder?: string
|
||||
autofocus?: boolean
|
||||
hideIcon?: boolean
|
||||
class?: string
|
||||
action?: JSX.Element
|
||||
}
|
||||
|
||||
export interface ListAddProps {
|
||||
class?: string
|
||||
render: () => JSX.Element
|
||||
}
|
||||
|
||||
export interface ListAddProps {
|
||||
class?: string
|
||||
render: () => JSX.Element
|
||||
}
|
||||
|
||||
export interface ListProps<T> extends FilteredListProps<T> {
|
||||
class?: string
|
||||
children: (item: T) => JSX.Element
|
||||
emptyMessage?: string
|
||||
loadingMessage?: string
|
||||
onKeyEvent?: (event: KeyboardEvent, item: T | undefined) => void
|
||||
onMove?: (item: T | undefined) => void
|
||||
onFilter?: (value: string) => void
|
||||
activeIcon?: IconProps["name"]
|
||||
filter?: string
|
||||
search?: ListSearchProps | boolean
|
||||
itemWrapper?: (item: T, node: JSX.Element) => JSX.Element
|
||||
divider?: boolean
|
||||
add?: ListAddProps
|
||||
groupHeader?: (group: { category: string; items: T[] }) => JSX.Element
|
||||
}
|
||||
|
||||
export interface ListRef {
|
||||
onKeyDown: (e: KeyboardEvent) => void
|
||||
setScrollRef: (el: HTMLDivElement | undefined) => void
|
||||
setFilter: (value: string) => void
|
||||
}
|
||||
|
||||
export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void }) {
|
||||
const i18n = useI18n()
|
||||
let inputRef: HTMLInputElement | HTMLTextAreaElement | undefined
|
||||
const [store, setStore] = createStore({
|
||||
mouseActive: false,
|
||||
scrollRef: undefined as HTMLDivElement | undefined,
|
||||
internalFilter: "",
|
||||
})
|
||||
const scrollRef = () => store.scrollRef
|
||||
const setScrollRef = (el: HTMLDivElement | undefined) => setStore("scrollRef", el)
|
||||
const internalFilter = () => store.internalFilter
|
||||
const setInternalFilter = (value: string) => setStore("internalFilter", value)
|
||||
|
||||
const scrollIntoView = (container: HTMLDivElement, node: HTMLElement, block: "center" | "nearest") => {
|
||||
const containerRect = container.getBoundingClientRect()
|
||||
const nodeRect = node.getBoundingClientRect()
|
||||
const top = nodeRect.top - containerRect.top + container.scrollTop
|
||||
const bottom = top + nodeRect.height
|
||||
const viewTop = container.scrollTop
|
||||
const viewBottom = viewTop + container.clientHeight
|
||||
const target =
|
||||
block === "center"
|
||||
? top - container.clientHeight / 2 + nodeRect.height / 2
|
||||
: top < viewTop
|
||||
? top
|
||||
: bottom > viewBottom
|
||||
? bottom - container.clientHeight
|
||||
: viewTop
|
||||
const max = Math.max(0, container.scrollHeight - container.clientHeight)
|
||||
container.scrollTop = Math.max(0, Math.min(target, max))
|
||||
}
|
||||
|
||||
const { filter, grouped, flat, active, setActive, onKeyDown, onInput, refetch } = useFilteredList<T>(props)
|
||||
|
||||
const searchProps = () => (typeof props.search === "object" ? props.search : {})
|
||||
const searchAction = () => searchProps().action
|
||||
const addProps = () => props.add
|
||||
const showAdd = () => !!addProps()
|
||||
|
||||
const moved = (event: MouseEvent) => event.movementX !== 0 || event.movementY !== 0
|
||||
|
||||
const applyFilter = (value: string, options?: { ref?: boolean }) => {
|
||||
const prev = filter()
|
||||
setInternalFilter(value)
|
||||
onInput(value)
|
||||
props.onFilter?.(value)
|
||||
|
||||
if (!options?.ref) return
|
||||
|
||||
// Force a refetch even if the value is unchanged.
|
||||
// This is important for programmatic changes like Tab completion.
|
||||
if (prev === value) {
|
||||
void refetch()
|
||||
return
|
||||
}
|
||||
queueMicrotask(() => refetch())
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (props.filter === undefined) return
|
||||
if (props.filter === internalFilter()) return
|
||||
setInternalFilter(props.filter)
|
||||
onInput(props.filter)
|
||||
})
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
filter,
|
||||
() => {
|
||||
scrollRef()?.scrollTo(0, 0)
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
const scroll = scrollRef()
|
||||
if (!scroll) return
|
||||
if (!props.current) return
|
||||
const key = props.key(props.current)
|
||||
requestAnimationFrame(() => {
|
||||
const element = findByKey(scroll, key)
|
||||
if (!element) return
|
||||
scrollIntoView(scroll, element, "center")
|
||||
})
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const all = flat()
|
||||
if (store.mouseActive || all.length === 0) return
|
||||
const scroll = scrollRef()
|
||||
if (!scroll) return
|
||||
if (active() === props.key(all[0])) {
|
||||
scroll.scrollTo(0, 0)
|
||||
return
|
||||
}
|
||||
const key = active()
|
||||
if (!key) return
|
||||
const element = findByKey(scroll, key)
|
||||
if (!element) return
|
||||
scrollIntoView(scroll, element, "center")
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const all = flat()
|
||||
const current = active()
|
||||
const item = all.find((x) => props.key(x) === current)
|
||||
props.onMove?.(item)
|
||||
})
|
||||
|
||||
const handleSelect = (item: T | undefined, index: number) => {
|
||||
props.onSelect?.(item, index)
|
||||
}
|
||||
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
setStore("mouseActive", false)
|
||||
if (e.key === "Escape") return
|
||||
|
||||
const all = flat()
|
||||
const selected = all.find((x) => props.key(x) === active())
|
||||
const index = selected ? all.indexOf(selected) : -1
|
||||
props.onKeyEvent?.(e, selected)
|
||||
|
||||
if (e.defaultPrevented) return
|
||||
|
||||
if (e.key === "Enter" && !e.isComposing) {
|
||||
e.preventDefault()
|
||||
if (selected) handleSelect(selected, index)
|
||||
} else if (props.search) {
|
||||
if (e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey && (e.key === "n" || e.key === "p")) {
|
||||
onKeyDown(e)
|
||||
return
|
||||
}
|
||||
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
||||
onKeyDown(e)
|
||||
}
|
||||
} else {
|
||||
onKeyDown(e)
|
||||
}
|
||||
}
|
||||
|
||||
props.ref?.({
|
||||
onKeyDown: handleKey,
|
||||
setScrollRef,
|
||||
setFilter: (value) => applyFilter(value, { ref: true }),
|
||||
})
|
||||
|
||||
const renderAdd = () => {
|
||||
const add = addProps()
|
||||
if (!add) return null
|
||||
return (
|
||||
<div data-slot="list-item-add" classList={{ [add.class ?? ""]: !!add.class }}>
|
||||
{add.render()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function GroupHeader(groupProps: { group: { category: string; items: T[] } }): JSX.Element {
|
||||
const [state, setState] = createStore({
|
||||
stuck: false,
|
||||
header: undefined as HTMLDivElement | undefined,
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const scroll = scrollRef()
|
||||
const node = state.header
|
||||
if (!scroll || !node) return
|
||||
|
||||
const handler = () => {
|
||||
const rect = node.getBoundingClientRect()
|
||||
const scrollRect = scroll.getBoundingClientRect()
|
||||
setState("stuck", rect.top <= scrollRect.top + 1 && scroll.scrollTop > 0)
|
||||
}
|
||||
|
||||
makeEventListener(scroll, "scroll", handler, { passive: true })
|
||||
handler()
|
||||
})
|
||||
|
||||
return (
|
||||
<div data-slot="list-header" data-stuck={state.stuck} ref={(el) => setState("header", el)}>
|
||||
{props.groupHeader?.(groupProps.group) ?? groupProps.group.category}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const emptyMessage = () => {
|
||||
if (grouped.loading) return props.loadingMessage ?? i18n.t("ui.list.loading")
|
||||
if (props.emptyMessage) return props.emptyMessage
|
||||
|
||||
const query = filter()
|
||||
if (!query) return i18n.t("ui.list.empty")
|
||||
|
||||
const suffix = i18n.t("ui.list.emptyWithFilter.suffix")
|
||||
return (
|
||||
<>
|
||||
<span>{i18n.t("ui.list.emptyWithFilter.prefix")}</span>
|
||||
<span data-slot="list-filter">"{query}"</span>
|
||||
<Show when={suffix}>
|
||||
<span>{suffix}</span>
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-component="list" classList={{ [props.class ?? ""]: !!props.class }}>
|
||||
<Show when={!!props.search}>
|
||||
<div data-slot="list-search-wrapper">
|
||||
<div
|
||||
data-slot="list-search"
|
||||
classList={{ [searchProps().class ?? ""]: !!searchProps().class }}
|
||||
onPointerDown={(event) => {
|
||||
const container = event.currentTarget
|
||||
if (!(container instanceof HTMLElement)) return
|
||||
|
||||
const node = container.querySelector("input, textarea")
|
||||
const input = node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement ? node : inputRef
|
||||
input?.focus()
|
||||
|
||||
// Prevent global listeners (e.g. dnd sensors) from cancelling focus.
|
||||
event.stopPropagation()
|
||||
}}
|
||||
>
|
||||
<div data-slot="list-search-container">
|
||||
<Show when={!searchProps().hideIcon}>
|
||||
<Icon name="magnifying-glass" />
|
||||
</Show>
|
||||
<TextField
|
||||
autofocus={searchProps().autofocus}
|
||||
variant="ghost"
|
||||
data-slot="list-search-input"
|
||||
type="text"
|
||||
ref={(el: HTMLInputElement | HTMLTextAreaElement) => {
|
||||
inputRef = el
|
||||
}}
|
||||
value={internalFilter()}
|
||||
onChange={(value) => applyFilter(value)}
|
||||
onKeyDown={handleKey}
|
||||
placeholder={searchProps().placeholder}
|
||||
spellcheck={false}
|
||||
autocorrect="off"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
/>
|
||||
</div>
|
||||
<Show when={internalFilter()}>
|
||||
<IconButton
|
||||
icon="circle-x"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setInternalFilter("")
|
||||
queueMicrotask(() => inputRef?.focus())
|
||||
}}
|
||||
aria-label={i18n.t("ui.list.clearFilter")}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
{searchAction()}
|
||||
</div>
|
||||
</Show>
|
||||
<div ref={setScrollRef} data-slot="list-scroll">
|
||||
<Show
|
||||
when={flat().length > 0 || showAdd()}
|
||||
fallback={
|
||||
<div data-slot="list-empty-state">
|
||||
<div data-slot="list-message">{emptyMessage()}</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<For each={grouped.latest}>
|
||||
{(group, groupIndex) => {
|
||||
const isLastGroup = () => groupIndex() === grouped.latest.length - 1
|
||||
return (
|
||||
<div data-slot="list-group">
|
||||
<Show when={group.category}>
|
||||
<GroupHeader group={group} />
|
||||
</Show>
|
||||
<div data-slot="list-items">
|
||||
<For each={group.items}>
|
||||
{(item, i) => {
|
||||
const node = (
|
||||
<button
|
||||
data-slot="list-item"
|
||||
data-key={props.key(item)}
|
||||
data-active={props.key(item) === active()}
|
||||
data-selected={item === props.current}
|
||||
onClick={() => handleSelect(item, i())}
|
||||
onKeyDown={handleKey}
|
||||
type="button"
|
||||
onMouseMove={(event) => {
|
||||
if (!moved(event)) return
|
||||
setStore("mouseActive", true)
|
||||
setActive(props.key(item))
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
if (!store.mouseActive) return
|
||||
setActive(null)
|
||||
}}
|
||||
>
|
||||
{props.children(item)}
|
||||
<Show when={item === props.current}>
|
||||
<span data-slot="list-item-selected-icon">
|
||||
<Icon name="check-small" />
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={props.activeIcon}>
|
||||
{(icon) => (
|
||||
<span data-slot="list-item-active-icon">
|
||||
<Icon name={icon()} />
|
||||
</span>
|
||||
)}
|
||||
</Show>
|
||||
{props.divider && (i() !== group.items.length - 1 || (showAdd() && isLastGroup())) && (
|
||||
<span data-slot="list-item-divider" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
if (props.itemWrapper) return props.itemWrapper(item, node)
|
||||
return node
|
||||
}}
|
||||
</For>
|
||||
<Show when={showAdd() && isLastGroup()}>{renderAdd()}</Show>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
<Show when={grouped.latest.length === 0 && showAdd()}>
|
||||
<div data-slot="list-group">
|
||||
<div data-slot="list-items">{renderAdd()}</div>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
4
qimingcode/packages/ui/src/components/logo.css
Normal file
4
qimingcode/packages/ui/src/components/logo.css
Normal file
@@ -0,0 +1,4 @@
|
||||
[data-component="logo-mark"] {
|
||||
width: 16px;
|
||||
aspect-ratio: 4/5;
|
||||
}
|
||||
57
qimingcode/packages/ui/src/components/logo.stories.tsx
Normal file
57
qimingcode/packages/ui/src/components/logo.stories.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
// @ts-nocheck
|
||||
import * as mod from "./logo"
|
||||
|
||||
const docs = `### Overview
|
||||
OpenCode logo assets: mark, splash, and wordmark.
|
||||
|
||||
Use Mark for compact spaces, Logo for headers, Splash for hero sections.
|
||||
|
||||
### API
|
||||
- \`Mark\`, \`Splash\`, and \`Logo\` components accept standard SVG props.
|
||||
|
||||
### Variants and states
|
||||
- Multiple logo variants for different contexts.
|
||||
|
||||
### Behavior
|
||||
- Pure SVG rendering.
|
||||
|
||||
### Accessibility
|
||||
- Provide title/aria-label when logos convey meaning.
|
||||
|
||||
### Theming/tokens
|
||||
- Uses theme color tokens via CSS variables.
|
||||
|
||||
`
|
||||
|
||||
export default {
|
||||
title: "UI/Logo",
|
||||
id: "components-logo",
|
||||
component: mod.Logo,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: docs,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const Basic = {
|
||||
render: () => (
|
||||
<div style={{ display: "grid", gap: "16px", "align-items": "start" }}>
|
||||
<div>
|
||||
<div style={{ color: "var(--text-weak)", "font-size": "12px" }}>Mark</div>
|
||||
<mod.Mark />
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ color: "var(--text-weak)", "font-size": "12px" }}>Splash</div>
|
||||
<mod.Splash style={{ width: "80px", height: "100px" }} />
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ color: "var(--text-weak)", "font-size": "12px" }}>Logo</div>
|
||||
<mod.Logo style={{ width: "200px" }} />
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
62
qimingcode/packages/ui/src/components/logo.tsx
Normal file
62
qimingcode/packages/ui/src/components/logo.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { ComponentProps } from "solid-js"
|
||||
|
||||
export const Mark = (props: { class?: string }) => {
|
||||
return (
|
||||
<svg
|
||||
data-component="logo-mark"
|
||||
classList={{ [props.class ?? ""]: !!props.class }}
|
||||
viewBox="0 0 16 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path data-slot="logo-logo-mark-shadow" d="M12 16H4V8H12V16Z" fill="var(--icon-weak-base)" />
|
||||
<path data-slot="logo-logo-mark-o" d="M12 4H4V16H12V4ZM16 20H0V0H16V20Z" fill="var(--icon-strong-base)" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export const Splash = (props: Pick<ComponentProps<"svg">, "ref" | "class">) => {
|
||||
return (
|
||||
<svg
|
||||
ref={props.ref}
|
||||
data-component="logo-splash"
|
||||
classList={{ [props.class ?? ""]: !!props.class }}
|
||||
viewBox="0 0 80 100"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M60 80H20V40H60V80Z" fill="var(--icon-base)" />
|
||||
<path d="M60 20H20V80H60V20ZM80 100H0V0H80V100Z" fill="var(--icon-strong-base)" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export const Logo = (props: { class?: string }) => {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 234 42"
|
||||
fill="none"
|
||||
classList={{ [props.class ?? ""]: !!props.class }}
|
||||
>
|
||||
<g>
|
||||
<path d="M18 30H6V18H18V30Z" fill="var(--icon-weak-base)" />
|
||||
<path d="M18 12H6V30H18V12ZM24 36H0V6H24V36Z" fill="var(--icon-base)" />
|
||||
<path d="M48 30H36V18H48V30Z" fill="var(--icon-weak-base)" />
|
||||
<path d="M36 30H48V12H36V30ZM54 36H36V42H30V6H54V36Z" fill="var(--icon-base)" />
|
||||
<path d="M84 24V30H66V24H84Z" fill="var(--icon-weak-base)" />
|
||||
<path d="M84 24H66V30H84V36H60V6H84V24ZM66 18H78V12H66V18Z" fill="var(--icon-base)" />
|
||||
<path d="M108 36H96V18H108V36Z" fill="var(--icon-weak-base)" />
|
||||
<path d="M108 12H96V36H90V6H108V12ZM114 36H108V12H114V36Z" fill="var(--icon-base)" />
|
||||
<path d="M144 30H126V18H144V30Z" fill="var(--icon-weak-base)" />
|
||||
<path d="M144 12H126V30H144V36H120V6H144V12Z" fill="var(--icon-strong-base)" />
|
||||
<path d="M168 30H156V18H168V30Z" fill="var(--icon-weak-base)" />
|
||||
<path d="M168 12H156V30H168V12ZM174 36H150V6H174V36Z" fill="var(--icon-strong-base)" />
|
||||
<path d="M198 30H186V18H198V30Z" fill="var(--icon-weak-base)" />
|
||||
<path d="M198 12H186V30H198V12ZM204 36H180V6H198V0H204V36Z" fill="var(--icon-strong-base)" />
|
||||
<path d="M234 24V30H216V24H234Z" fill="var(--icon-weak-base)" />
|
||||
<path d="M216 12V18H228V12H216ZM234 24H216V30H234V36H210V6H234V24Z" fill="var(--icon-strong-base)" />
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { stream } from "./markdown-stream"
|
||||
|
||||
describe("markdown stream", () => {
|
||||
test("heals incomplete emphasis while streaming", () => {
|
||||
expect(stream("hello **world", true)).toEqual([{ raw: "hello **world", src: "hello **world**", mode: "live" }])
|
||||
expect(stream("say `code", true)).toEqual([{ raw: "say `code", src: "say `code`", mode: "live" }])
|
||||
})
|
||||
|
||||
test("keeps incomplete links non-clickable until they finish", () => {
|
||||
expect(stream("see [docs](https://example.com/gu", true)).toEqual([
|
||||
{ raw: "see [docs](https://example.com/gu", src: "see docs", mode: "live" },
|
||||
])
|
||||
})
|
||||
|
||||
test("splits an unfinished trailing code fence from stable content", () => {
|
||||
expect(stream("before\n\n```ts\nconst x = 1", true)).toEqual([
|
||||
{ raw: "before\n\n", src: "before\n\n", mode: "live" },
|
||||
{ raw: "```ts\nconst x = 1", src: "```ts\nconst x = 1", mode: "live" },
|
||||
])
|
||||
})
|
||||
|
||||
test("keeps reference-style markdown as one block", () => {
|
||||
expect(stream("[docs][1]\n\n[1]: https://example.com", true)).toEqual([
|
||||
{
|
||||
raw: "[docs][1]\n\n[1]: https://example.com",
|
||||
src: "[docs][1]\n\n[1]: https://example.com",
|
||||
mode: "live",
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
49
qimingcode/packages/ui/src/components/markdown-stream.ts
Normal file
49
qimingcode/packages/ui/src/components/markdown-stream.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { marked, type Tokens } from "marked"
|
||||
import remend from "remend"
|
||||
|
||||
export type Block = {
|
||||
raw: string
|
||||
src: string
|
||||
mode: "full" | "live"
|
||||
}
|
||||
|
||||
function refs(text: string) {
|
||||
return /^\[[^\]]+\]:\s+\S+/m.test(text) || /^\[\^[^\]]+\]:\s+/m.test(text)
|
||||
}
|
||||
|
||||
function open(raw: string) {
|
||||
const match = raw.match(/^[ \t]{0,3}(`{3,}|~{3,})/)
|
||||
if (!match) return false
|
||||
const mark = match[1]
|
||||
if (!mark) return false
|
||||
const char = mark[0]
|
||||
const size = mark.length
|
||||
const last = raw.trimEnd().split("\n").at(-1)?.trim() ?? ""
|
||||
return !new RegExp(`^[\\t ]{0,3}${char}{${size},}[\\t ]*$`).test(last)
|
||||
}
|
||||
|
||||
function heal(text: string) {
|
||||
return remend(text, { linkMode: "text-only" })
|
||||
}
|
||||
|
||||
export function stream(text: string, live: boolean) {
|
||||
if (!live) return [{ raw: text, src: text, mode: "full" }] satisfies Block[]
|
||||
const src = heal(text)
|
||||
if (refs(text)) return [{ raw: text, src, mode: "live" }] satisfies Block[]
|
||||
const tokens = marked.lexer(text)
|
||||
const tail = tokens.findLastIndex((token) => token.type !== "space")
|
||||
if (tail < 0) return [{ raw: text, src, mode: "live" }] satisfies Block[]
|
||||
const last = tokens[tail]
|
||||
if (!last || last.type !== "code") return [{ raw: text, src, mode: "live" }] satisfies Block[]
|
||||
const code = last as Tokens.Code
|
||||
if (!open(code.raw)) return [{ raw: text, src, mode: "live" }] satisfies Block[]
|
||||
const head = tokens
|
||||
.slice(0, tail)
|
||||
.map((token) => token.raw)
|
||||
.join("")
|
||||
if (!head) return [{ raw: code.raw, src: code.raw, mode: "live" }] satisfies Block[]
|
||||
return [
|
||||
{ raw: head, src: heal(head), mode: "live" },
|
||||
{ raw: code.raw, src: code.raw, mode: "live" },
|
||||
] satisfies Block[]
|
||||
}
|
||||
266
qimingcode/packages/ui/src/components/markdown.css
Normal file
266
qimingcode/packages/ui/src/components/markdown.css
Normal file
@@ -0,0 +1,266 @@
|
||||
[data-component="markdown"] {
|
||||
/* Reset & Base Typography */
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
overflow-wrap: break-word;
|
||||
color: var(--text-strong);
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-base); /* 14px */
|
||||
line-height: 160%;
|
||||
|
||||
/* Spacing for flow */
|
||||
> *:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
> *:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Headings: Same size, distinguished by color and spacing */
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-size: 14px;
|
||||
color: var(--text-strong);
|
||||
font-weight: var(--font-weight-medium);
|
||||
margin-top: 0px;
|
||||
margin-bottom: 24px;
|
||||
line-height: var(--line-height-large);
|
||||
}
|
||||
|
||||
/* Emphasis & Strong: Neutral strong color */
|
||||
strong,
|
||||
b {
|
||||
color: var(--text-strong);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
/* Paragraphs */
|
||||
p {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* Links */
|
||||
a {
|
||||
color: var(--text-interactive-base);
|
||||
text-decoration: none;
|
||||
font-weight: inherit;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Lists */
|
||||
ul,
|
||||
ol {
|
||||
margin-top: 8px;
|
||||
margin-bottom: 12px;
|
||||
margin-left: 0;
|
||||
padding-left: 32px;
|
||||
list-style-position: outside;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
ol {
|
||||
list-style-type: decimal;
|
||||
padding-left: 2.25rem;
|
||||
}
|
||||
|
||||
li {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
li > p:first-child {
|
||||
display: inline;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
li > p + p {
|
||||
display: block;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
li::marker {
|
||||
color: var(--text-weak);
|
||||
}
|
||||
|
||||
/* Nested lists spacing */
|
||||
li > ul,
|
||||
li > ol {
|
||||
margin-top: 0.25rem;
|
||||
margin-bottom: 0.25rem;
|
||||
padding-left: 1rem; /* Minimal indent for nesting only */
|
||||
}
|
||||
|
||||
li > ol {
|
||||
padding-left: 1.75rem;
|
||||
}
|
||||
|
||||
/* Blockquotes */
|
||||
blockquote {
|
||||
border-left: 2px solid var(--border-weak-base);
|
||||
margin: 1.5rem 0;
|
||||
padding-left: 0.5rem;
|
||||
color: var(--text-weak);
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
/* Horizontal Rule - Invisible spacing only */
|
||||
hr {
|
||||
border: none;
|
||||
height: 0;
|
||||
margin: 40px 0;
|
||||
}
|
||||
|
||||
.shiki {
|
||||
font-size: 13px;
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
border: 0.5px solid var(--border-weak-base);
|
||||
}
|
||||
|
||||
[data-component="markdown-code"] {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
[data-slot="markdown-copy-button"] {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
z-index: 1;
|
||||
|
||||
&::after {
|
||||
content: attr(data-tooltip);
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: calc(100% + 4px);
|
||||
transform: translateX(-50%);
|
||||
z-index: 1000;
|
||||
|
||||
max-width: 320px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-float-base);
|
||||
color: var(--text-invert-strong);
|
||||
padding: 2px 8px;
|
||||
border: 1px solid var(--border-weak-base, rgba(0, 0, 0, 0.07));
|
||||
box-shadow: var(--shadow-md);
|
||||
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-small);
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: var(--line-height-large);
|
||||
letter-spacing: var(--letter-spacing-normal);
|
||||
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="markdown-copy-button"]:hover::after,
|
||||
[data-slot="markdown-copy-button"]:focus-visible::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
[data-slot="markdown-copy-button"][data-variant="secondary"] {
|
||||
box-shadow: none;
|
||||
border: 1px solid var(--border-weak-base);
|
||||
}
|
||||
|
||||
[data-slot="markdown-copy-button"][data-variant="secondary"] [data-slot="icon-svg"] {
|
||||
color: var(--icon-base);
|
||||
}
|
||||
|
||||
[data-component="markdown-code"]:hover [data-slot="markdown-copy-button"] {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
[data-slot="markdown-copy-button"] [data-slot="check-icon"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-slot="markdown-copy-button"][data-copied="true"] [data-slot="copy-icon"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-slot="markdown-copy-button"][data-copied="true"] [data-slot="check-icon"] {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
pre {
|
||||
margin-top: 12px;
|
||||
margin-bottom: 32px;
|
||||
overflow: auto;
|
||||
|
||||
scrollbar-width: none;
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
:not(pre) > code {
|
||||
font-family: var(--font-family-mono);
|
||||
font-feature-settings: var(--font-family-mono--font-feature-settings);
|
||||
color: var(--syntax-string);
|
||||
font-weight: var(--font-weight-medium);
|
||||
/* font-size: 13px; */
|
||||
|
||||
/* padding: 2px 2px; */
|
||||
/* margin: 0 1.5px; */
|
||||
/* border-radius: 2px; */
|
||||
/* background: var(--surface-base); */
|
||||
/* box-shadow: 0 0 0 0.5px var(--border-weak-base); */
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 24px 0;
|
||||
font-size: var(--font-size-base);
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
/* Minimal borders for structure, matching TUI "lines" roughly but keeping it web-clean */
|
||||
border-bottom: 1px solid var(--border-weaker-base);
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
th {
|
||||
color: var(--text-strong);
|
||||
font-weight: var(--font-weight-medium);
|
||||
border-bottom: 1px solid var(--border-weak-base);
|
||||
}
|
||||
|
||||
/* Images */
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 4px;
|
||||
margin: 1.5rem 0;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="markdown"] a.external-link:hover > code {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
53
qimingcode/packages/ui/src/components/markdown.stories.tsx
Normal file
53
qimingcode/packages/ui/src/components/markdown.stories.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
// @ts-nocheck
|
||||
import * as mod from "./markdown"
|
||||
import { create } from "../storybook/scaffold"
|
||||
import { markdown } from "../storybook/fixtures"
|
||||
|
||||
const docs = `### Overview
|
||||
Render sanitized Markdown with code blocks, inline code, and safe links.
|
||||
|
||||
Pair with \`Code\` for standalone code views.
|
||||
|
||||
### API
|
||||
- Required: \`text\` Markdown string.
|
||||
- Uses the Marked context provider for parsing and sanitization.
|
||||
|
||||
### Variants and states
|
||||
- Code blocks include copy buttons when rendered.
|
||||
|
||||
### Behavior
|
||||
- Sanitizes HTML and auto-converts inline URL code to links.
|
||||
- Adds copy buttons to code blocks.
|
||||
|
||||
### Accessibility
|
||||
- Copy buttons include aria-labels from i18n.
|
||||
- TODO: confirm link target behavior in sanitized output.
|
||||
|
||||
### Theming/tokens
|
||||
- Uses \`data-component="markdown"\` and related slots for styling.
|
||||
|
||||
`
|
||||
|
||||
const story = create({
|
||||
title: "UI/Markdown",
|
||||
mod,
|
||||
args: {
|
||||
text: markdown,
|
||||
},
|
||||
})
|
||||
|
||||
export default {
|
||||
title: "UI/Markdown",
|
||||
id: "components-markdown",
|
||||
component: story.meta.component,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: docs,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const Basic = story.Basic
|
||||
348
qimingcode/packages/ui/src/components/markdown.tsx
Normal file
348
qimingcode/packages/ui/src/components/markdown.tsx
Normal file
@@ -0,0 +1,348 @@
|
||||
import { useMarked } from "../context/marked"
|
||||
import { useI18n } from "../context/i18n"
|
||||
import DOMPurify from "dompurify"
|
||||
import morphdom from "morphdom"
|
||||
import { checksum } from "@opencode-ai/core/util/encode"
|
||||
import { ComponentProps, createEffect, createResource, createSignal, onCleanup, splitProps } from "solid-js"
|
||||
import { isServer } from "solid-js/web"
|
||||
import { stream } from "./markdown-stream"
|
||||
|
||||
type Entry = {
|
||||
hash: string
|
||||
html: string
|
||||
}
|
||||
|
||||
const max = 200
|
||||
const cache = new Map<string, Entry>()
|
||||
|
||||
if (typeof window !== "undefined" && DOMPurify.isSupported) {
|
||||
DOMPurify.addHook("afterSanitizeAttributes", (node: Element) => {
|
||||
if (!(node instanceof HTMLAnchorElement)) return
|
||||
if (node.target !== "_blank") return
|
||||
|
||||
const rel = node.getAttribute("rel") ?? ""
|
||||
const set = new Set(rel.split(/\s+/).filter(Boolean))
|
||||
set.add("noopener")
|
||||
set.add("noreferrer")
|
||||
node.setAttribute("rel", Array.from(set).join(" "))
|
||||
})
|
||||
}
|
||||
|
||||
const config = {
|
||||
USE_PROFILES: { html: true, mathMl: true },
|
||||
SANITIZE_NAMED_PROPS: true,
|
||||
FORBID_TAGS: ["style"],
|
||||
FORBID_CONTENTS: ["style", "script"],
|
||||
}
|
||||
|
||||
const iconPaths = {
|
||||
copy: '<path d="M6.2513 6.24935V2.91602H17.0846V13.7493H13.7513M13.7513 6.24935V17.0827H2.91797V6.24935H13.7513Z" stroke="currentColor" stroke-linecap="round"/>',
|
||||
check: '<path d="M5 11.9657L8.37838 14.7529L15 5.83398" stroke="currentColor" stroke-linecap="square"/>',
|
||||
}
|
||||
|
||||
function sanitize(html: string) {
|
||||
if (!DOMPurify.isSupported) return ""
|
||||
return DOMPurify.sanitize(html, config)
|
||||
}
|
||||
|
||||
function escape(text: string) {
|
||||
return text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'")
|
||||
}
|
||||
|
||||
function fallback(markdown: string) {
|
||||
return escape(markdown).replace(/\r\n?/g, "\n").replace(/\n/g, "<br>")
|
||||
}
|
||||
|
||||
type CopyLabels = {
|
||||
copy: string
|
||||
copied: string
|
||||
}
|
||||
|
||||
const urlPattern = /^https?:\/\/[^\s<>()`"']+$/
|
||||
|
||||
function codeUrl(text: string) {
|
||||
const href = text.trim().replace(/[),.;!?]+$/, "")
|
||||
if (!urlPattern.test(href)) return
|
||||
try {
|
||||
const url = new URL(href)
|
||||
return url.toString()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
function createIcon(path: string, slot: string) {
|
||||
const icon = document.createElement("div")
|
||||
icon.setAttribute("data-component", "icon")
|
||||
icon.setAttribute("data-size", "small")
|
||||
icon.setAttribute("data-slot", slot)
|
||||
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg")
|
||||
svg.setAttribute("data-slot", "icon-svg")
|
||||
svg.setAttribute("fill", "none")
|
||||
svg.setAttribute("viewBox", "0 0 20 20")
|
||||
svg.setAttribute("aria-hidden", "true")
|
||||
svg.innerHTML = path
|
||||
icon.appendChild(svg)
|
||||
return icon
|
||||
}
|
||||
|
||||
function createCopyButton(labels: CopyLabels) {
|
||||
const button = document.createElement("button")
|
||||
button.type = "button"
|
||||
button.setAttribute("data-component", "icon-button")
|
||||
button.setAttribute("data-variant", "secondary")
|
||||
button.setAttribute("data-size", "small")
|
||||
button.setAttribute("data-slot", "markdown-copy-button")
|
||||
button.setAttribute("aria-label", labels.copy)
|
||||
button.setAttribute("data-tooltip", labels.copy)
|
||||
button.appendChild(createIcon(iconPaths.copy, "copy-icon"))
|
||||
button.appendChild(createIcon(iconPaths.check, "check-icon"))
|
||||
return button
|
||||
}
|
||||
|
||||
function setCopyState(button: HTMLButtonElement, labels: CopyLabels, copied: boolean) {
|
||||
if (copied) {
|
||||
button.setAttribute("data-copied", "true")
|
||||
button.setAttribute("aria-label", labels.copied)
|
||||
button.setAttribute("data-tooltip", labels.copied)
|
||||
return
|
||||
}
|
||||
button.removeAttribute("data-copied")
|
||||
button.setAttribute("aria-label", labels.copy)
|
||||
button.setAttribute("data-tooltip", labels.copy)
|
||||
}
|
||||
|
||||
function ensureCodeWrapper(block: HTMLPreElement, labels: CopyLabels) {
|
||||
const parent = block.parentElement
|
||||
if (!parent) return
|
||||
const wrapped = parent.getAttribute("data-component") === "markdown-code"
|
||||
if (!wrapped) {
|
||||
const wrapper = document.createElement("div")
|
||||
wrapper.setAttribute("data-component", "markdown-code")
|
||||
parent.replaceChild(wrapper, block)
|
||||
wrapper.appendChild(block)
|
||||
wrapper.appendChild(createCopyButton(labels))
|
||||
return
|
||||
}
|
||||
|
||||
const buttons = Array.from(parent.querySelectorAll('[data-slot="markdown-copy-button"]')).filter(
|
||||
(el): el is HTMLButtonElement => el instanceof HTMLButtonElement,
|
||||
)
|
||||
|
||||
if (buttons.length === 0) {
|
||||
parent.appendChild(createCopyButton(labels))
|
||||
return
|
||||
}
|
||||
|
||||
for (const button of buttons.slice(1)) {
|
||||
button.remove()
|
||||
}
|
||||
}
|
||||
|
||||
function markCodeLinks(root: HTMLDivElement) {
|
||||
const codeNodes = Array.from(root.querySelectorAll(":not(pre) > code"))
|
||||
for (const code of codeNodes) {
|
||||
const href = codeUrl(code.textContent ?? "")
|
||||
const parentLink =
|
||||
code.parentElement instanceof HTMLAnchorElement && code.parentElement.classList.contains("external-link")
|
||||
? code.parentElement
|
||||
: null
|
||||
|
||||
if (!href) {
|
||||
if (parentLink) parentLink.replaceWith(code)
|
||||
continue
|
||||
}
|
||||
|
||||
if (parentLink) {
|
||||
parentLink.href = href
|
||||
continue
|
||||
}
|
||||
|
||||
const link = document.createElement("a")
|
||||
link.href = href
|
||||
link.className = "external-link"
|
||||
link.target = "_blank"
|
||||
link.rel = "noopener noreferrer"
|
||||
code.parentNode?.replaceChild(link, code)
|
||||
link.appendChild(code)
|
||||
}
|
||||
}
|
||||
|
||||
function decorate(root: HTMLDivElement, labels: CopyLabels) {
|
||||
const blocks = Array.from(root.querySelectorAll("pre"))
|
||||
for (const block of blocks) {
|
||||
ensureCodeWrapper(block, labels)
|
||||
}
|
||||
markCodeLinks(root)
|
||||
}
|
||||
|
||||
function setupCodeCopy(root: HTMLDivElement, getLabels: () => CopyLabels) {
|
||||
const timeouts = new Map<HTMLButtonElement, ReturnType<typeof setTimeout>>()
|
||||
|
||||
const updateLabel = (button: HTMLButtonElement) => {
|
||||
const labels = getLabels()
|
||||
const copied = button.getAttribute("data-copied") === "true"
|
||||
setCopyState(button, labels, copied)
|
||||
}
|
||||
|
||||
const handleClick = async (event: MouseEvent) => {
|
||||
const target = event.target
|
||||
if (!(target instanceof Element)) return
|
||||
|
||||
const button = target.closest('[data-slot="markdown-copy-button"]')
|
||||
if (!(button instanceof HTMLButtonElement)) return
|
||||
const code = button.closest('[data-component="markdown-code"]')?.querySelector("code")
|
||||
const content = code?.textContent ?? ""
|
||||
if (!content) return
|
||||
const clipboard = navigator?.clipboard
|
||||
if (!clipboard) return
|
||||
await clipboard.writeText(content)
|
||||
const labels = getLabels()
|
||||
setCopyState(button, labels, true)
|
||||
const existing = timeouts.get(button)
|
||||
if (existing) clearTimeout(existing)
|
||||
const timeout = setTimeout(() => setCopyState(button, labels, false), 2000)
|
||||
timeouts.set(button, timeout)
|
||||
}
|
||||
|
||||
const buttons = Array.from(root.querySelectorAll('[data-slot="markdown-copy-button"]'))
|
||||
for (const button of buttons) {
|
||||
if (button instanceof HTMLButtonElement) updateLabel(button)
|
||||
}
|
||||
|
||||
root.addEventListener("click", handleClick)
|
||||
|
||||
return () => {
|
||||
root.removeEventListener("click", handleClick)
|
||||
for (const timeout of timeouts.values()) {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function touch(key: string, value: Entry) {
|
||||
cache.delete(key)
|
||||
cache.set(key, value)
|
||||
|
||||
if (cache.size <= max) return
|
||||
|
||||
const first = cache.keys().next().value
|
||||
if (!first) return
|
||||
cache.delete(first)
|
||||
}
|
||||
|
||||
export function Markdown(
|
||||
props: ComponentProps<"div"> & {
|
||||
text: string
|
||||
cacheKey?: string
|
||||
streaming?: boolean
|
||||
class?: string
|
||||
classList?: Record<string, boolean>
|
||||
},
|
||||
) {
|
||||
const [local, others] = splitProps(props, ["text", "cacheKey", "streaming", "class", "classList"])
|
||||
const marked = useMarked()
|
||||
const i18n = useI18n()
|
||||
const [root, setRoot] = createSignal<HTMLDivElement>()
|
||||
const [html] = createResource(
|
||||
() => ({
|
||||
text: local.text,
|
||||
key: local.cacheKey,
|
||||
streaming: local.streaming ?? false,
|
||||
}),
|
||||
async (src) => {
|
||||
if (isServer) return fallback(src.text)
|
||||
if (!src.text) return ""
|
||||
|
||||
const base = src.key ?? checksum(src.text)
|
||||
return Promise.all(
|
||||
stream(src.text, src.streaming).map(async (block, index) => {
|
||||
const hash = checksum(block.raw)
|
||||
const key = base ? `${base}:${index}:${block.mode}` : hash
|
||||
|
||||
if (key && hash) {
|
||||
const cached = cache.get(key)
|
||||
if (cached && cached.hash === hash) {
|
||||
touch(key, cached)
|
||||
return cached.html
|
||||
}
|
||||
}
|
||||
|
||||
const next = await Promise.resolve(marked.parse(block.src))
|
||||
const safe = sanitize(next)
|
||||
if (key && hash) touch(key, { hash, html: safe })
|
||||
return safe
|
||||
}),
|
||||
)
|
||||
.then((list) => list.join(""))
|
||||
.catch(() => fallback(src.text))
|
||||
},
|
||||
{ initialValue: fallback(local.text) },
|
||||
)
|
||||
|
||||
let copyCleanup: (() => void) | undefined
|
||||
|
||||
createEffect(() => {
|
||||
const container = root()
|
||||
const content = local.text ? (html.latest ?? html() ?? "") : ""
|
||||
if (!container) return
|
||||
if (isServer) return
|
||||
|
||||
if (!content) {
|
||||
container.innerHTML = ""
|
||||
return
|
||||
}
|
||||
|
||||
const labels = {
|
||||
copy: i18n.t("ui.message.copy"),
|
||||
copied: i18n.t("ui.message.copied"),
|
||||
}
|
||||
const temp = document.createElement("div")
|
||||
temp.innerHTML = content
|
||||
decorate(temp, labels)
|
||||
|
||||
morphdom(container, temp, {
|
||||
childrenOnly: true,
|
||||
onBeforeElUpdated: (fromEl, toEl) => {
|
||||
if (
|
||||
fromEl instanceof HTMLButtonElement &&
|
||||
toEl instanceof HTMLButtonElement &&
|
||||
fromEl.getAttribute("data-slot") === "markdown-copy-button" &&
|
||||
toEl.getAttribute("data-slot") === "markdown-copy-button" &&
|
||||
fromEl.getAttribute("data-copied") === "true"
|
||||
) {
|
||||
setCopyState(toEl, labels, true)
|
||||
}
|
||||
if (fromEl.isEqualNode(toEl)) return false
|
||||
return true
|
||||
},
|
||||
})
|
||||
|
||||
if (!copyCleanup)
|
||||
copyCleanup = setupCodeCopy(container, () => ({
|
||||
copy: i18n.t("ui.message.copy"),
|
||||
copied: i18n.t("ui.message.copied"),
|
||||
}))
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
if (copyCleanup) copyCleanup()
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
data-component="markdown"
|
||||
classList={{
|
||||
...local.classList,
|
||||
[local.class ?? ""]: !!local.class,
|
||||
}}
|
||||
ref={setRoot}
|
||||
{...others}
|
||||
/>
|
||||
)
|
||||
}
|
||||
55
qimingcode/packages/ui/src/components/message-file.test.ts
Normal file
55
qimingcode/packages/ui/src/components/message-file.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { FilePart } from "@opencode-ai/sdk/v2"
|
||||
import { attached, inline, kind } from "./message-file"
|
||||
|
||||
function file(part: Partial<FilePart> = {}): FilePart {
|
||||
return {
|
||||
id: "part_1",
|
||||
sessionID: "ses_1",
|
||||
messageID: "msg_1",
|
||||
type: "file",
|
||||
mime: "text/plain",
|
||||
url: "file:///repo/README.txt",
|
||||
filename: "README.txt",
|
||||
...part,
|
||||
}
|
||||
}
|
||||
|
||||
describe("message-file", () => {
|
||||
test("treats data URLs as attachments", () => {
|
||||
expect(attached(file({ url: "data:text/plain;base64,SGVsbG8=" }))).toBe(true)
|
||||
expect(attached(file())).toBe(false)
|
||||
})
|
||||
|
||||
test("treats only non-attachment source ranges as inline references", () => {
|
||||
expect(
|
||||
inline(
|
||||
file({
|
||||
source: {
|
||||
type: "file",
|
||||
path: "/repo/README.txt",
|
||||
text: { value: "@README.txt", start: 0, end: 11 },
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toBe(true)
|
||||
|
||||
expect(
|
||||
inline(
|
||||
file({
|
||||
url: "data:text/plain;base64,SGVsbG8=",
|
||||
source: {
|
||||
type: "file",
|
||||
path: "/repo/README.txt",
|
||||
text: { value: "@README.txt", start: 0, end: 11 },
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
test("separates image and file attachment kinds", () => {
|
||||
expect(kind(file({ mime: "image/png" }))).toBe("image")
|
||||
expect(kind(file({ mime: "application/pdf" }))).toBe("file")
|
||||
})
|
||||
})
|
||||
14
qimingcode/packages/ui/src/components/message-file.ts
Normal file
14
qimingcode/packages/ui/src/components/message-file.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { FilePart } from "@opencode-ai/sdk/v2"
|
||||
|
||||
export function attached(part: FilePart) {
|
||||
return part.url.startsWith("data:")
|
||||
}
|
||||
|
||||
export function inline(part: FilePart) {
|
||||
if (attached(part)) return false
|
||||
return part.source?.text?.start !== undefined && part.source?.text?.end !== undefined
|
||||
}
|
||||
|
||||
export function kind(part: FilePart) {
|
||||
return part.mime.startsWith("image/") ? "image" : "file"
|
||||
}
|
||||
123
qimingcode/packages/ui/src/components/message-nav.css
Normal file
123
qimingcode/packages/ui/src/components/message-nav.css
Normal file
@@ -0,0 +1,123 @@
|
||||
[data-component="message-nav"] {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
padding-left: 0;
|
||||
list-style: none;
|
||||
|
||||
&[data-size="normal"] {
|
||||
width: 240px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
&[data-size="compact"] {
|
||||
width: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="message-nav-item"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-self: stretch;
|
||||
justify-content: flex-end;
|
||||
|
||||
[data-component="message-nav"][data-size="normal"] & {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="message-nav-tick-button"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
height: 12px;
|
||||
width: 24px;
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0;
|
||||
|
||||
&[data-active] [data-slot="message-nav-tick-line"] {
|
||||
background-color: var(--icon-strong-base);
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="message-nav-tick-line"] {
|
||||
height: 1px;
|
||||
width: 16px;
|
||||
background-color: var(--icon-base);
|
||||
transition:
|
||||
width 0.2s,
|
||||
background-color 0.2s;
|
||||
}
|
||||
|
||||
[data-slot="message-nav-tick-button"]:hover [data-slot="message-nav-tick-line"] {
|
||||
width: 100%;
|
||||
background-color: var(--icon-strong-base);
|
||||
}
|
||||
|
||||
[data-slot="message-nav-message-button"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-self: stretch;
|
||||
width: 100%;
|
||||
column-gap: 12px;
|
||||
cursor: default;
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 4px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
[data-slot="message-nav-title-preview"] {
|
||||
font-size: 14px; /* text-14-regular */
|
||||
color: var(--text-base);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
|
||||
&[data-active] {
|
||||
color: var(--text-strong);
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="message-nav-item"]:hover [data-slot="message-nav-message-button"] {
|
||||
background-color: var(--surface-base);
|
||||
}
|
||||
[data-slot="message-nav-item"]:active [data-slot="message-nav-message-button"] {
|
||||
background-color: var(--surface-base-active);
|
||||
}
|
||||
|
||||
[data-slot="message-nav-item"]:active [data-slot="message-nav-title-preview"] {
|
||||
color: var(--text-base);
|
||||
}
|
||||
|
||||
.message-nav-tooltip {
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
[data-slot="message-nav-tooltip-content"] {
|
||||
display: flex;
|
||||
padding: 4px 4px 6px 4px;
|
||||
justify-content: center;
|
||||
align-items: start;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface-raised-stronger-non-alpha);
|
||||
max-height: calc(100vh - 6rem);
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
|
||||
/* border/shadow-xs/base */
|
||||
box-shadow:
|
||||
0 0 0 1px var(--border-weak-base, rgba(17, 0, 0, 0.12)),
|
||||
0 1px 2px -1px rgba(19, 16, 16, 0.04),
|
||||
0 1px 2px 0 rgba(19, 16, 16, 0.06),
|
||||
0 1px 3px 0 rgba(19, 16, 16, 0.08);
|
||||
|
||||
* {
|
||||
margin: 0 !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
// @ts-nocheck
|
||||
import * as mod from "./message-nav"
|
||||
import { create } from "../storybook/scaffold"
|
||||
|
||||
const story = create({ title: "UI/MessageNav", mod })
|
||||
export default { title: "UI/MessageNav", id: "components-message-nav", component: story.meta.component }
|
||||
export const Basic = story.Basic
|
||||
92
qimingcode/packages/ui/src/components/message-nav.tsx
Normal file
92
qimingcode/packages/ui/src/components/message-nav.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import { ComponentProps, For, Match, Show, splitProps, Switch } from "solid-js"
|
||||
import { DiffChanges } from "./diff-changes"
|
||||
import { Tooltip } from "./tooltip"
|
||||
import { useI18n } from "../context/i18n"
|
||||
|
||||
export function MessageNav(
|
||||
props: ComponentProps<"ul"> & {
|
||||
messages: UserMessage[]
|
||||
current?: UserMessage
|
||||
size: "normal" | "compact"
|
||||
onMessageSelect: (message: UserMessage) => void
|
||||
getLabel?: (message: UserMessage) => string | undefined
|
||||
},
|
||||
) {
|
||||
const i18n = useI18n()
|
||||
const [local, others] = splitProps(props, ["messages", "current", "size", "onMessageSelect", "getLabel"])
|
||||
|
||||
const content = () => (
|
||||
<ul role="list" data-component="message-nav" data-size={local.size} {...others}>
|
||||
<For each={local.messages}>
|
||||
{(message) => {
|
||||
const handleClick = () => local.onMessageSelect(message)
|
||||
|
||||
const handleKeyPress = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") return
|
||||
event.preventDefault()
|
||||
local.onMessageSelect(message)
|
||||
}
|
||||
|
||||
return (
|
||||
<li data-slot="message-nav-item">
|
||||
<Switch>
|
||||
<Match when={local.size === "compact"}>
|
||||
<div
|
||||
data-slot="message-nav-tick-button"
|
||||
data-active={message.id === local.current?.id || undefined}
|
||||
role="button"
|
||||
tabindex={0}
|
||||
onClick={handleClick}
|
||||
onKeyDown={handleKeyPress}
|
||||
>
|
||||
<div data-slot="message-nav-tick-line" />
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={local.size === "normal"}>
|
||||
<button data-slot="message-nav-message-button" onClick={handleClick} onKeyDown={handleKeyPress}>
|
||||
<DiffChanges changes={message.summary?.diffs ?? []} variant="bars" />
|
||||
<div
|
||||
data-slot="message-nav-title-preview"
|
||||
data-active={message.id === local.current?.id || undefined}
|
||||
>
|
||||
<Show
|
||||
when={local.getLabel?.(message) ?? message.summary?.title}
|
||||
fallback={i18n.t("ui.messageNav.newMessage")}
|
||||
>
|
||||
{local.getLabel?.(message) ?? message.summary?.title}
|
||||
</Show>
|
||||
</div>
|
||||
</button>
|
||||
</Match>
|
||||
</Switch>
|
||||
</li>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</ul>
|
||||
)
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={local.size === "compact"}>
|
||||
<Tooltip
|
||||
openDelay={0}
|
||||
placement="right-start"
|
||||
gutter={-40}
|
||||
shift={-10}
|
||||
overlap
|
||||
contentClass="message-nav-tooltip"
|
||||
value={
|
||||
<div data-slot="message-nav-tooltip-content">
|
||||
<MessageNav {...props} size="normal" class="" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{content()}
|
||||
</Tooltip>
|
||||
</Match>
|
||||
<Match when={local.size === "normal"}>{content()}</Match>
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
1276
qimingcode/packages/ui/src/components/message-part.css
Normal file
1276
qimingcode/packages/ui/src/components/message-part.css
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,7 @@
|
||||
// @ts-nocheck
|
||||
import * as mod from "./message-part"
|
||||
import { create } from "../storybook/scaffold"
|
||||
|
||||
const story = create({ title: "UI/MessagePart", mod })
|
||||
export default { title: "UI/MessagePart", id: "components-message-part", component: story.meta.component }
|
||||
export const Basic = story.Basic
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user