mirror of
https://seed.radicle.dev/rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5.git
synced 2026-11-15 19:30:38 +08:00
desktop: Extend shared form components
`Checkbox` reports changes, `SegmentedSwitch` options can show an icon and a label together, `TextInput` takes a `maxlength`, and a new `Select` is a styled dropdown that also works inside a popover, where a nested `Popover` would close its parent. Co-authored-by: Brandon Oxendine <brandon.oxendine@gmail.com>
This commit is contained in:
co-authored by
Brandon Oxendine
parent
c13c7fa5d9
commit
e29def0dda
@@ -7,6 +7,7 @@
|
||||
checked?: boolean;
|
||||
disabled?: boolean;
|
||||
children?: Snippet;
|
||||
onchange?: (checked: boolean) => void;
|
||||
}
|
||||
|
||||
/* eslint-disable prefer-const */
|
||||
@@ -14,6 +15,7 @@
|
||||
checked = $bindable(false),
|
||||
disabled = false,
|
||||
children,
|
||||
onchange,
|
||||
}: Props = $props();
|
||||
/* eslint-enable prefer-const */
|
||||
</script>
|
||||
@@ -80,7 +82,11 @@
|
||||
</style>
|
||||
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" bind:checked {disabled} />
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked
|
||||
{disabled}
|
||||
onchange={() => onchange?.(checked)} />
|
||||
<span class="box"><Icon name="checkmark" /></span>
|
||||
{#if children}
|
||||
<span class="label">{@render children()}</span>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
interface Props {
|
||||
/// Two or more mutually exclusive choices, in the order they are shown. An
|
||||
/// option carries either a glyph or a word, not both.
|
||||
/// option carries a glyph, a word, or both.
|
||||
options: {
|
||||
value: T;
|
||||
label?: string;
|
||||
@@ -42,7 +42,8 @@
|
||||
styleJustifyContent="center">
|
||||
{#if option.icon}
|
||||
<Icon name={option.icon} />
|
||||
{:else}
|
||||
{/if}
|
||||
{#if option.label}
|
||||
{option.label}
|
||||
{/if}
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
<script lang="ts" generics="T extends string">
|
||||
import {
|
||||
autoUpdate,
|
||||
computePosition,
|
||||
flip,
|
||||
offset,
|
||||
shift,
|
||||
} from "@floating-ui/dom";
|
||||
|
||||
import DropdownListItem from "@app/components/DropdownListItem.svelte";
|
||||
import Icon from "@app/components/Icon.svelte";
|
||||
|
||||
interface Props {
|
||||
options: { value: T; label: string }[];
|
||||
value: T;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
title?: string;
|
||||
onchange?: (value: T) => void;
|
||||
}
|
||||
|
||||
/* eslint-disable prefer-const */
|
||||
let {
|
||||
options,
|
||||
value = $bindable(),
|
||||
label,
|
||||
disabled = false,
|
||||
title,
|
||||
onchange,
|
||||
}: Props = $props();
|
||||
/* eslint-enable prefer-const */
|
||||
|
||||
let open = $state(false);
|
||||
let active = $state(0);
|
||||
let containerEl: HTMLDivElement | undefined = $state();
|
||||
let triggerEl: HTMLButtonElement | undefined = $state();
|
||||
let listEl: HTMLDivElement | undefined = $state();
|
||||
|
||||
const current = $derived(options.find(o => o.value === value));
|
||||
|
||||
function show() {
|
||||
if (disabled) return;
|
||||
active = Math.max(
|
||||
0,
|
||||
options.findIndex(o => o.value === value),
|
||||
);
|
||||
open = true;
|
||||
}
|
||||
|
||||
function pick(next: T) {
|
||||
value = next;
|
||||
onchange?.(next);
|
||||
open = false;
|
||||
triggerEl?.focus();
|
||||
}
|
||||
|
||||
function onkeydown(event: KeyboardEvent) {
|
||||
if (disabled) return;
|
||||
switch (event.key) {
|
||||
case "ArrowDown":
|
||||
case "ArrowUp": {
|
||||
event.preventDefault();
|
||||
if (!open) {
|
||||
show();
|
||||
return;
|
||||
}
|
||||
const step = event.key === "ArrowDown" ? 1 : -1;
|
||||
active = (active + step + options.length) % options.length;
|
||||
break;
|
||||
}
|
||||
case "Enter":
|
||||
case " ":
|
||||
if (open) {
|
||||
event.preventDefault();
|
||||
pick(options[active].value);
|
||||
}
|
||||
break;
|
||||
case "Escape":
|
||||
// Only this list closes, not a menu or modal it sits in.
|
||||
if (open) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
open = false;
|
||||
}
|
||||
break;
|
||||
case "Tab":
|
||||
open = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function onWindowPointerDown(event: PointerEvent) {
|
||||
if (open && containerEl && !event.composedPath().includes(containerEl)) {
|
||||
open = false;
|
||||
}
|
||||
}
|
||||
|
||||
// The list stays inside the component rather than in a portal, so a click
|
||||
// in it counts as a click inside any popover or modal the select sits in.
|
||||
$effect(() => {
|
||||
if (!open || !triggerEl || !listEl) return;
|
||||
const trigger = triggerEl;
|
||||
const list = listEl;
|
||||
return autoUpdate(trigger, list, () => {
|
||||
void computePosition(trigger, list, {
|
||||
strategy: "fixed",
|
||||
placement: "bottom-start",
|
||||
middleware: [offset(4), flip(), shift({ padding: 8 })],
|
||||
}).then(({ x, y }) => {
|
||||
list.style.left = `${x}px`;
|
||||
list.style.top = `${y}px`;
|
||||
list.style.visibility = "visible";
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.select {
|
||||
display: inline-flex;
|
||||
min-width: 0;
|
||||
}
|
||||
.trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
height: 2rem;
|
||||
padding: 0 0.5rem;
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: var(--border-radius-sm);
|
||||
background-color: var(--color-surface-canvas);
|
||||
color: var(--color-text-primary);
|
||||
font: var(--txt-body-m-regular);
|
||||
cursor: pointer;
|
||||
}
|
||||
.trigger:focus-visible,
|
||||
.trigger.open {
|
||||
outline: none;
|
||||
border-color: var(--color-border-brand);
|
||||
}
|
||||
.trigger:disabled {
|
||||
color: var(--color-text-disabled);
|
||||
background-color: var(--color-surface-base);
|
||||
cursor: default;
|
||||
}
|
||||
.chevron {
|
||||
display: flex;
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
.list {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 500;
|
||||
visibility: hidden;
|
||||
min-width: 10rem;
|
||||
padding: 0.25rem;
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: var(--border-radius-sm);
|
||||
background-color: var(--color-surface-canvas);
|
||||
box-shadow: var(--elevation-low);
|
||||
}
|
||||
.check {
|
||||
display: flex;
|
||||
margin-left: auto;
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
</style>
|
||||
|
||||
<svelte:window onpointerdown={onWindowPointerDown} />
|
||||
|
||||
<div class="select" bind:this={containerEl}>
|
||||
<button
|
||||
bind:this={triggerEl}
|
||||
type="button"
|
||||
class="trigger"
|
||||
class:open
|
||||
aria-label={label}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
{disabled}
|
||||
{title}
|
||||
onclick={() => (open ? (open = false) : show())}
|
||||
{onkeydown}>
|
||||
<span class="txt-overflow">{current?.label ?? ""}</span>
|
||||
<span class="chevron">
|
||||
<Icon name={open ? "chevron-up" : "chevron-down"} />
|
||||
</span>
|
||||
</button>
|
||||
{#if open}
|
||||
<div class="list" role="listbox" aria-label={label} bind:this={listEl}>
|
||||
{#each options as option, index (option.value)}
|
||||
<DropdownListItem
|
||||
selected={index === active}
|
||||
styleGap="0.5rem"
|
||||
styleWidth="100%"
|
||||
onclick={() => pick(option.value)}>
|
||||
<span>{option.label}</span>
|
||||
{#if option.value === value}
|
||||
<span class="check"><Icon name="checkmark" /></span>
|
||||
{/if}
|
||||
</DropdownListItem>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -12,6 +12,7 @@
|
||||
disabled?: boolean;
|
||||
keyShortcuts?: string;
|
||||
left?: Snippet;
|
||||
maxlength?: number;
|
||||
// Lets shortcuts with Mod through while focused, for fields like filters
|
||||
// that hold nothing worth protecting.
|
||||
modShortcuts?: boolean;
|
||||
@@ -37,6 +38,7 @@
|
||||
disabled = false,
|
||||
keyShortcuts,
|
||||
left,
|
||||
maxlength,
|
||||
modShortcuts = false,
|
||||
name,
|
||||
onDismiss,
|
||||
@@ -157,6 +159,7 @@
|
||||
{type}
|
||||
{name}
|
||||
{placeholder}
|
||||
{maxlength}
|
||||
{disabled}
|
||||
bind:value
|
||||
autocomplete="off"
|
||||
|
||||
Reference in New Issue
Block a user