mirror of
https://github.com/gradio-app/gradio.git
synced 2025-03-31 12:20:26 +08:00
Accessibility Improvements (#5554)
* allow remove token via keyboard * more a11y enhancements * upload + dataset a11y tweaks * add changeset * add webcam label * improve checkbox focus styling and allow interaction via keyboard * add changeset * improve radio focus color * tweak * add radio label * add changeset * add annotated image alt + use button for labels * button tweaks * add changeset * tweak * more changes * tiny tweaks * galley / image * label tweaks and add semantic tags to confidence * nit + docstring * tweak * add changeset * fix tests * unit test fix * range tweak * fix alignment in gallery * range tweak * slider test tweak * tweak * more test fixes * last? test tweak --------- Co-authored-by: gradio-pr-bot <gradio-pr-bot@users.noreply.github.com>
This commit is contained in:
parent
9ccc4794a7
commit
75ddeb390d
24
.changeset/rich-hoops-rescue.md
Normal file
24
.changeset/rich-hoops-rescue.md
Normal file
@ -0,0 +1,24 @@
|
||||
---
|
||||
"@gradio/accordion": minor
|
||||
"@gradio/annotatedimage": minor
|
||||
"@gradio/app": minor
|
||||
"@gradio/button": minor
|
||||
"@gradio/chatbot": minor
|
||||
"@gradio/checkbox": minor
|
||||
"@gradio/checkboxgroup": minor
|
||||
"@gradio/code": minor
|
||||
"@gradio/dropdown": minor
|
||||
"@gradio/gallery": minor
|
||||
"@gradio/image": minor
|
||||
"@gradio/json": minor
|
||||
"@gradio/label": minor
|
||||
"@gradio/number": minor
|
||||
"@gradio/plot": minor
|
||||
"@gradio/radio": minor
|
||||
"@gradio/slider": minor
|
||||
"@gradio/textbox": minor
|
||||
"@gradio/upload": minor
|
||||
"gradio": minor
|
||||
---
|
||||
|
||||
feat:Accessibility Improvements
|
@ -91,7 +91,7 @@ class Textbox(
|
||||
min_width: minimum pixel width, will wrap if not sufficient screen space to satisfy this value. If a certain scale value results in this Component being narrower than min_width, the min_width parameter will be respected first.
|
||||
interactive: if True, will be rendered as an editable textbox; if False, editing will be disabled. If not provided, this is inferred based on whether the component is used as an input or output.
|
||||
visible: If False, component will be hidden.
|
||||
autofocus: If True, will focus on the textbox when the page loads.
|
||||
autofocus: If True, will focus on the textbox when the page loads. Use this carefully, as it can cause usability issues for sighted and non-sighted users.
|
||||
elem_id: An optional string that is assigned as the id of this component in the HTML DOM. Can be used for targeting CSS styles.
|
||||
elem_classes: An optional list of strings that are assigned as the classes of this component in the HTML DOM. Can be used for targeting CSS styles.
|
||||
type: The type of textbox. One of: 'text', 'password', 'email', Default is 'text'.
|
||||
|
@ -3,15 +3,12 @@
|
||||
export let open = true;
|
||||
</script>
|
||||
|
||||
<!-- TODO: fix -->
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div on:click={() => (open = !open)} class="label-wrap" class:open>
|
||||
<button on:click={() => (open = !open)} class="label-wrap" class:open>
|
||||
<span>{label}</span>
|
||||
<span style:transform={open ? "rotate(0)" : "rotate(90deg)"} class="icon">
|
||||
▼
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
<div style:display={open ? "block" : "none"}>
|
||||
<slot />
|
||||
</div>
|
||||
|
@ -41,8 +41,8 @@
|
||||
normalise_file(value[0], root, root_url) as FileData,
|
||||
value[1].map(([file, _label]) => [
|
||||
normalise_file(file, root, root_url) as FileData,
|
||||
_label
|
||||
])
|
||||
_label,
|
||||
]),
|
||||
];
|
||||
} else {
|
||||
_value = null;
|
||||
@ -58,7 +58,7 @@
|
||||
function handle_click(i: number): void {
|
||||
gradio.dispatch("select", {
|
||||
value: label,
|
||||
index: i
|
||||
index: i,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
@ -83,15 +83,15 @@
|
||||
<Empty size="large" unpadded_box={true}><Image /></Empty>
|
||||
{:else}
|
||||
<div class="image-container">
|
||||
<!-- svelte-ignore a11y-missing-attribute -->
|
||||
<img
|
||||
class="base-image"
|
||||
class:fit-height={height}
|
||||
src={_value ? _value[0].data : null}
|
||||
alt="uploaded file"
|
||||
/>
|
||||
{#each _value ? _value[1] : [] as [file, label], i}
|
||||
<!-- svelte-ignore a11y-missing-attribute -->
|
||||
<img
|
||||
alt="segmentation mask identifying {label} within the uploaded file"
|
||||
class="mask fit-height"
|
||||
class:active={active == label}
|
||||
class:inactive={active != label && active != null}
|
||||
@ -107,10 +107,7 @@
|
||||
{#if show_legend && _value}
|
||||
<div class="legend">
|
||||
{#each _value[1] as [_, label], i}
|
||||
<!-- TODO: fix -->
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div
|
||||
<button
|
||||
class="legend-item"
|
||||
style="background-color: {color_map && label in color_map
|
||||
? color_map[label] + '88'
|
||||
@ -124,7 +121,7 @@
|
||||
on:click={() => handle_click(i)}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
@ -13,7 +13,7 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, shrink-to-fit=no, maximum-scale=1"
|
||||
content="width=device-width, initial-scale=1, shrink-to-fit=no"
|
||||
/>
|
||||
|
||||
<script type="module" src="./src/main.ts"></script>
|
||||
|
@ -39,7 +39,7 @@
|
||||
{$_("common.hosted_on")}
|
||||
<a class="hf" href="https://huggingface.co/spaces"
|
||||
><span class="space-logo">
|
||||
<img src={space_logo} alt={`Hugging Face Space }`} />
|
||||
<img src={space_logo} alt="Hugging Face Space" />
|
||||
</span> Spaces</a
|
||||
>
|
||||
</span>
|
||||
|
@ -69,7 +69,7 @@
|
||||
$: component_meta = selected_samples.map((sample_row) =>
|
||||
sample_row.map((sample_cell, j) => ({
|
||||
value: sample_cell,
|
||||
component: component_map[components[j]] as ComponentType<SvelteComponent>
|
||||
component: component_map[components[j]] as ComponentType<SvelteComponent>,
|
||||
}))
|
||||
);
|
||||
</script>
|
||||
@ -130,7 +130,7 @@
|
||||
</div>
|
||||
{:else}
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<table tabindex="0" role="grid">
|
||||
<thead>
|
||||
<tr class="tr-head">
|
||||
{#each headers as header}
|
||||
|
@ -67,8 +67,7 @@
|
||||
<div class="interpretation">
|
||||
<canvas bind:this={saliency_layer} />
|
||||
</div>
|
||||
<!-- svelte-ignore a11y-missing-attribute -->
|
||||
<img bind:this={image} src={original} />
|
||||
<img bind:this={image} src={original} alt="uploaded input" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -20,10 +20,9 @@ test("updates frontend correctly", async ({ page }) => {
|
||||
});
|
||||
|
||||
test("updates backend correctly", async ({ page }) => {
|
||||
const min_slider = await page.getByLabel("min");
|
||||
const max_slider = await page.getByLabel("max");
|
||||
const num = await page.getByLabel("input");
|
||||
const output = await page.getByLabel("out");
|
||||
const min_slider = await page.getByLabel("number input for min");
|
||||
const num = await page.getByLabel("input").first();
|
||||
const output = await page.getByLabel("output");
|
||||
|
||||
await min_slider.fill("10");
|
||||
await num.fill("15");
|
||||
|
@ -12,8 +12,8 @@ test("renders the correct elements", async ({ page }) => {
|
||||
});
|
||||
|
||||
test("can run an api request and display the data", async ({ page }) => {
|
||||
await page.getByLabel("Covid").check();
|
||||
await page.getByLabel("Lung Cancer").check();
|
||||
await page.getByTitle("Covid").check();
|
||||
await page.getByTitle("Lung Cancer").check();
|
||||
|
||||
const run_button = await page.locator("button", { hasText: /Run/ }).first();
|
||||
|
||||
|
@ -12,7 +12,7 @@ test("test inputs", async ({ page }) => {
|
||||
await textbox2.fill("hello world");
|
||||
await expect(textbox2).toHaveValue("hello world");
|
||||
|
||||
const number = await page.getByLabel("Number");
|
||||
const number = await page.getByLabel("Number").first();
|
||||
await expect(number).toHaveValue("42");
|
||||
await number.fill("10");
|
||||
await expect(number).toHaveValue("10");
|
||||
|
@ -4,9 +4,9 @@ test("selecting matplotlib should show matplotlib image and pressing clear shoul
|
||||
page
|
||||
}) => {
|
||||
await page.getByLabel("Plot Type").click();
|
||||
await page.getByRole("button", { name: "Matplotlib" }).click();
|
||||
await page.getByRole("option", { name: "Matplotlib" }).click();
|
||||
await page.getByLabel("Month").click();
|
||||
await page.getByRole("button", { name: "January" }).click();
|
||||
await page.getByRole("option", { name: "January" }).click();
|
||||
await page.getByLabel("Social Distancing?").check();
|
||||
|
||||
await Promise.all([
|
||||
@ -26,9 +26,9 @@ test("selecting plotly should show plotly plot and pressing clear should clear o
|
||||
page
|
||||
}) => {
|
||||
await page.getByLabel("Plot Type").click();
|
||||
await page.getByRole("button", { name: "Plotly" }).click();
|
||||
await page.getByRole("option", { name: "Plotly" }).click();
|
||||
await page.getByLabel("Month").click();
|
||||
await page.getByRole("button", { name: "January" }).click();
|
||||
await page.getByRole("option", { name: "January" }).click();
|
||||
await page.getByLabel("Social Distancing?").check();
|
||||
|
||||
await Promise.all([
|
||||
@ -44,9 +44,9 @@ test("selecting altair should show altair plot and pressing clear should clear o
|
||||
page
|
||||
}) => {
|
||||
await page.getByLabel("Plot Type").click();
|
||||
await page.getByRole("button", { name: "altair" }).click();
|
||||
await page.getByRole("option", { name: "altair" }).click();
|
||||
await page.getByLabel("Month").click();
|
||||
await page.getByRole("button", { name: "January" }).click();
|
||||
await page.getByRole("option", { name: "January" }).click();
|
||||
await page.getByLabel("Social Distancing?").check();
|
||||
|
||||
await Promise.all([
|
||||
@ -66,9 +66,9 @@ test("switching between all 3 plot types and pressing submit should update outpu
|
||||
}) => {
|
||||
//Matplotlib
|
||||
await page.getByLabel("Plot Type").click();
|
||||
await page.getByRole("button", { name: "Matplotlib" }).click();
|
||||
await page.getByRole("option", { name: "Matplotlib" }).click();
|
||||
await page.getByLabel("Month").click();
|
||||
await page.getByRole("button", { name: "January" }).click();
|
||||
await page.getByRole("option", { name: "January" }).click();
|
||||
await page.getByLabel("Social Distancing?").check();
|
||||
|
||||
await Promise.all([
|
||||
@ -82,7 +82,7 @@ test("switching between all 3 plot types and pressing submit should update outpu
|
||||
|
||||
//Plotly
|
||||
await page.getByLabel("Plot Type").click();
|
||||
await page.getByRole("button", { name: "Plotly" }).click();
|
||||
await page.getByRole("option", { name: "Plotly" }).click();
|
||||
|
||||
await Promise.all([
|
||||
page.click("text=Submit"),
|
||||
@ -92,7 +92,7 @@ test("switching between all 3 plot types and pressing submit should update outpu
|
||||
|
||||
//Altair
|
||||
await page.getByLabel("Plot Type").click();
|
||||
await page.getByRole("button", { name: "Altair" }).click();
|
||||
await page.getByRole("option", { name: "Altair" }).click();
|
||||
|
||||
await Promise.all([
|
||||
page.click("text=Submit"),
|
||||
|
@ -34,7 +34,7 @@
|
||||
id={elem_id}
|
||||
>
|
||||
{#if icon}
|
||||
<img class="button-icon" src={icon_path} alt={`${value}-icon`} />
|
||||
<img class="button-icon" src={icon_path} alt={`${value} icon`} />
|
||||
{/if}
|
||||
<slot />
|
||||
</a>
|
||||
@ -52,7 +52,7 @@
|
||||
{disabled}
|
||||
>
|
||||
{#if icon}
|
||||
<img class="button-icon" src={icon_path} alt={`${value}-icon`} />
|
||||
<img class="button-icon" src={icon_path} alt={`${value} icon`} />
|
||||
{/if}
|
||||
<slot />
|
||||
</button>
|
||||
|
@ -44,12 +44,17 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<button on:click={handle_copy} title="copy">
|
||||
<button
|
||||
on:click={handle_copy}
|
||||
title="copy"
|
||||
aria-roledescription={copied ? "Value copied" : "Copy value"}
|
||||
aria-label={copied ? "Copied" : "Copy"}
|
||||
>
|
||||
{#if !copied}
|
||||
<span><Copy /> </span>
|
||||
<Copy />
|
||||
{/if}
|
||||
{#if copied}
|
||||
<span><Check /></span>
|
||||
<Check />
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
|
@ -28,12 +28,22 @@
|
||||
<label class:disabled>
|
||||
<input
|
||||
bind:checked={value}
|
||||
on:keydown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
value = !value;
|
||||
dispatch("select", {
|
||||
index: 0,
|
||||
value: label,
|
||||
selected: value,
|
||||
});
|
||||
}
|
||||
}}
|
||||
on:input={(evt) => {
|
||||
value = evt.currentTarget.checked;
|
||||
dispatch("select", {
|
||||
index: 0,
|
||||
value: label,
|
||||
selected: evt.currentTarget.checked
|
||||
selected: evt.currentTarget.checked,
|
||||
});
|
||||
}}
|
||||
{disabled}
|
||||
@ -77,6 +87,12 @@
|
||||
background-color: var(--checkbox-background-color-selected);
|
||||
}
|
||||
|
||||
input:checked:focus {
|
||||
background-image: var(--checkbox-check);
|
||||
background-color: var(--checkbox-background-color-selected);
|
||||
border-color: var(--checkbox-border-color-focus);
|
||||
}
|
||||
|
||||
input:hover {
|
||||
border-color: var(--checkbox-border-color-hover);
|
||||
background-color: var(--checkbox-background-color-hover);
|
||||
|
@ -58,11 +58,22 @@
|
||||
dispatch("select", {
|
||||
index: i,
|
||||
value: choice[1],
|
||||
selected: evt.currentTarget.checked
|
||||
selected: evt.currentTarget.checked,
|
||||
})}
|
||||
on:keydown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
toggleChoice(choice[1]);
|
||||
dispatch("select", {
|
||||
index: i,
|
||||
value: choice[1],
|
||||
selected: !value.includes(choice[1]),
|
||||
});
|
||||
}
|
||||
}}
|
||||
checked={value.includes(choice[1])}
|
||||
type="checkbox"
|
||||
name="test"
|
||||
name={choice[1]?.toString()}
|
||||
title={choice[1]?.toString()}
|
||||
/>
|
||||
<span class="ml-2">{choice[0]}</span>
|
||||
</label>
|
||||
@ -125,14 +136,19 @@
|
||||
background-color: var(--checkbox-background-color-selected);
|
||||
}
|
||||
|
||||
input:checked:focus {
|
||||
border-color: var(--checkbox-border-color-focus);
|
||||
background-image: var(--checkbox-check);
|
||||
background-color: var(--checkbox-background-color-selected);
|
||||
}
|
||||
|
||||
input:hover {
|
||||
border-color: var(--checkbox-border-color-hover);
|
||||
background-color: var(--checkbox-background-color-hover);
|
||||
}
|
||||
|
||||
input:focus {
|
||||
input:not(:checked):focus {
|
||||
border-color: var(--checkbox-border-color-focus);
|
||||
background-color: var(--checkbox-background-color-focus);
|
||||
}
|
||||
|
||||
input[disabled],
|
||||
|
@ -4,7 +4,7 @@
|
||||
import {
|
||||
EditorView,
|
||||
keymap,
|
||||
placeholder as placeholderExt
|
||||
placeholder as placeholderExt,
|
||||
} from "@codemirror/view";
|
||||
import { StateEffect, EditorState, type Extension } from "@codemirror/state";
|
||||
import { indentWithTab } from "@codemirror/commands";
|
||||
@ -42,7 +42,7 @@
|
||||
|
||||
$: reconfigure(), lang_extension;
|
||||
$: setDoc(value);
|
||||
$: updateLines(lines);
|
||||
$: updateLines();
|
||||
|
||||
function setDoc(newDoc: string): void {
|
||||
if (view && newDoc !== view.state.doc.toString()) {
|
||||
@ -50,13 +50,13 @@
|
||||
changes: {
|
||||
from: 0,
|
||||
to: view.state.doc.length,
|
||||
insert: newDoc
|
||||
}
|
||||
insert: newDoc,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function updateLines(newLines: number): void {
|
||||
function updateLines(): void {
|
||||
if (view) {
|
||||
view.requestMeasure({ read: updateGutters });
|
||||
}
|
||||
@ -65,7 +65,7 @@
|
||||
function createEditorView(): EditorView {
|
||||
return new EditorView({
|
||||
parent: element,
|
||||
state: createEditorState(value)
|
||||
state: createEditorState(value),
|
||||
});
|
||||
}
|
||||
|
||||
@ -119,7 +119,7 @@
|
||||
),
|
||||
FontTheme,
|
||||
...getTheme(),
|
||||
...extensions
|
||||
...extensions,
|
||||
];
|
||||
return stateExtensions;
|
||||
}
|
||||
@ -127,36 +127,36 @@
|
||||
const FontTheme = EditorView.theme({
|
||||
"&": {
|
||||
fontSize: "var(--text-sm)",
|
||||
backgroundColor: "var(--border-color-secondary)"
|
||||
backgroundColor: "var(--border-color-secondary)",
|
||||
},
|
||||
".cm-content": {
|
||||
paddingTop: "5px",
|
||||
paddingBottom: "5px",
|
||||
color: "var(--body-text-color)",
|
||||
fontFamily: "var(--font-mono)",
|
||||
minHeight: "100%"
|
||||
minHeight: "100%",
|
||||
},
|
||||
".cm-gutters": {
|
||||
marginRight: "1px",
|
||||
borderRight: "1px solid var(--border-color-primary)",
|
||||
backgroundColor: "transparent",
|
||||
color: "var(--body-text-color-subdued)"
|
||||
color: "var(--body-text-color-subdued)",
|
||||
},
|
||||
".cm-focused": {
|
||||
outline: "none"
|
||||
outline: "none",
|
||||
},
|
||||
".cm-scroller": {
|
||||
height: "auto"
|
||||
height: "auto",
|
||||
},
|
||||
".cm-cursor": {
|
||||
borderLeftColor: "var(--body-text-color)"
|
||||
}
|
||||
borderLeftColor: "var(--body-text-color)",
|
||||
},
|
||||
});
|
||||
|
||||
function createEditorState(_value: string | null | undefined): EditorState {
|
||||
return EditorState.create({
|
||||
doc: _value ?? undefined,
|
||||
extensions: getExtensions()
|
||||
extensions: getExtensions(),
|
||||
});
|
||||
}
|
||||
|
||||
@ -169,7 +169,8 @@
|
||||
): Extension[] {
|
||||
const extensions: Extension[] = [
|
||||
EditorView.editable.of(!readonly),
|
||||
EditorState.readOnly.of(readonly)
|
||||
EditorState.readOnly.of(readonly),
|
||||
EditorView.contentAttributes.of({ "aria-label": "Code input container" }),
|
||||
];
|
||||
|
||||
if (basic) {
|
||||
@ -202,7 +203,7 @@
|
||||
|
||||
function reconfigure(): void {
|
||||
view?.dispatch({
|
||||
effects: StateEffect.reconfigure.of(getExtensions())
|
||||
effects: StateEffect.reconfigure.of(getExtensions()),
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -27,12 +27,21 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<button on:click={handle_copy} title="copy">
|
||||
<!-- {#if !copied} -->
|
||||
<span class="copy-text" class:copied><Copy /> </span>
|
||||
<!-- {/if} -->
|
||||
<button
|
||||
on:click={handle_copy}
|
||||
title="copy"
|
||||
class:copied
|
||||
aria-roledescription="Copy value"
|
||||
aria-label="Copy"
|
||||
>
|
||||
<Copy />
|
||||
{#if copied}
|
||||
<span class="check" transition:fade><Check /></span>
|
||||
<span
|
||||
class="check"
|
||||
transition:fade
|
||||
aria-roledescription="Value copied"
|
||||
aria-label="Copied"><Check /></span
|
||||
>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
|
@ -74,8 +74,6 @@
|
||||
|
||||
<div class="reference" bind:this={refElement} />
|
||||
{#if show_options && !disabled}
|
||||
<!-- TODO: fix-->
|
||||
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
|
||||
<ul
|
||||
class="options"
|
||||
transition:fly={{ duration: 200, y: 5 }}
|
||||
@ -85,13 +83,11 @@
|
||||
style:max-height={`calc(${max_height}px - var(--window-padding))`}
|
||||
style:width={input_width + "px"}
|
||||
bind:this={listElement}
|
||||
role="listbox"
|
||||
>
|
||||
{#each filtered_indices as index}
|
||||
<!-- TODO: fix-->
|
||||
<!-- svelte-ignore a11y-no-noninteractive-element-to-interactive-role -->
|
||||
<li
|
||||
class="item"
|
||||
role="button"
|
||||
class:selected={selected_indices.includes(index)}
|
||||
class:active={index === active_index}
|
||||
class:bg-gray-100={index === active_index}
|
||||
@ -99,6 +95,8 @@
|
||||
data-index={index}
|
||||
aria-label={choices[index][0]}
|
||||
data-testid="dropdown-option"
|
||||
role="option"
|
||||
aria-selected={selected_indices.includes(index)}
|
||||
>
|
||||
<span class:hide={!selected_indices.includes(index)} class="inner-item">
|
||||
✓
|
||||
|
@ -221,13 +221,7 @@
|
||||
<div class="wrap">
|
||||
<div class="wrap-inner" class:show_options>
|
||||
{#each selected_indices as s}
|
||||
<!-- TODO: fix -->
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions-->
|
||||
<div
|
||||
on:click|preventDefault={() => remove_selected_choice(s)}
|
||||
class="token"
|
||||
>
|
||||
<div class="token">
|
||||
<span>
|
||||
{#if typeof s === "number"}
|
||||
{choices_names[s]}
|
||||
@ -236,7 +230,18 @@
|
||||
{/if}
|
||||
</span>
|
||||
{#if !disabled}
|
||||
<div class="token-remove" title={$_("common.remove") + " " + s}>
|
||||
<div
|
||||
class="token-remove"
|
||||
on:click|preventDefault={() => remove_selected_choice(s)}
|
||||
on:keydown|preventDefault={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
remove_selected_choice(s);
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
title={$_("common.remove") + " " + s}
|
||||
>
|
||||
<Remove />
|
||||
</div>
|
||||
{/if}
|
||||
@ -257,15 +262,20 @@
|
||||
on:focus={handle_focus}
|
||||
readonly={!filterable}
|
||||
/>
|
||||
<!-- TODO: fix -->
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions-->
|
||||
|
||||
{#if !disabled}
|
||||
{#if selected_indices.length > 0}
|
||||
<div
|
||||
role="button"
|
||||
tabindex="0"
|
||||
class="token-remove remove-all"
|
||||
title={$_("common.clear")}
|
||||
on:click={remove_all}
|
||||
on:keydown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
remove_all(event);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Remove />
|
||||
</div>
|
||||
|
@ -127,7 +127,7 @@
|
||||
if (selected_image !== null) {
|
||||
dispatch("select", {
|
||||
index: selected_image,
|
||||
value: _value?.[selected_image][1]
|
||||
value: _value?.[selected_image][1],
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -160,7 +160,7 @@
|
||||
|
||||
container_element?.scrollTo({
|
||||
left: pos < 0 ? 0 : pos,
|
||||
behavior: "smooth"
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
|
||||
@ -177,8 +177,7 @@
|
||||
<Empty unpadded_box={true} size="large"><Image /></Empty>
|
||||
{:else}
|
||||
{#if selected_image !== null && allow_preview}
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div on:keydown={on_keydown} class="preview">
|
||||
<button on:keydown={on_keydown} class="preview">
|
||||
<div class="icon-buttons">
|
||||
{#if show_download_button}
|
||||
<a
|
||||
@ -195,24 +194,27 @@
|
||||
on:clear={() => (selected_image = null)}
|
||||
/>
|
||||
</div>
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
|
||||
<img
|
||||
data-testid="detailed-image"
|
||||
<button
|
||||
class="image-button"
|
||||
on:click={(event) => handle_preview_click(event)}
|
||||
src={_value[selected_image][0].data}
|
||||
alt={_value[selected_image][1] || ""}
|
||||
title={_value[selected_image][1] || null}
|
||||
class:with-caption={!!_value[selected_image][1]}
|
||||
style="height: calc(100% - {_value[selected_image][1]
|
||||
? '80px'
|
||||
: '60px'})"
|
||||
loading="lazy"
|
||||
/>
|
||||
aria-label="detailed view of selected image"
|
||||
>
|
||||
<img
|
||||
data-testid="detailed-image"
|
||||
src={_value[selected_image][0].data}
|
||||
alt={_value[selected_image][1] || ""}
|
||||
title={_value[selected_image][1] || null}
|
||||
class:with-caption={!!_value[selected_image][1]}
|
||||
loading="lazy"
|
||||
/>
|
||||
</button>
|
||||
{#if _value[selected_image][1]}
|
||||
<div class="caption">
|
||||
<caption class="caption">
|
||||
{_value[selected_image][1]}
|
||||
</div>
|
||||
</caption>
|
||||
{/if}
|
||||
<div
|
||||
bind:this={container_element}
|
||||
@ -225,17 +227,18 @@
|
||||
on:click={() => (selected_image = i)}
|
||||
class="thumbnail-item thumbnail-small"
|
||||
class:selected={selected_image === i}
|
||||
aria-label={"Thumbnail " + (i + 1) + " of " + _value.length}
|
||||
>
|
||||
<img
|
||||
src={image[0].data}
|
||||
title={image[1] || null}
|
||||
alt={image[1] || null}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
/>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<div
|
||||
@ -263,6 +266,7 @@
|
||||
class="thumbnail-item thumbnail-lg"
|
||||
class:selected={selected_image === i}
|
||||
on:click={() => (selected_image = i)}
|
||||
aria-label={"Thumbnail " + (i + 1) + " of " + _value.length}
|
||||
>
|
||||
<img
|
||||
alt={caption || ""}
|
||||
@ -306,14 +310,19 @@
|
||||
}
|
||||
}
|
||||
|
||||
.image-button {
|
||||
height: calc(100% - 60px);
|
||||
width: 100%;
|
||||
display: flex;
|
||||
}
|
||||
.preview img {
|
||||
width: var(--size-full);
|
||||
height: calc(var(--size-full) - 60px);
|
||||
height: var(--size-full);
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.preview img.with-caption {
|
||||
height: calc(var(--size-full) - 80px);
|
||||
height: var(--size-full);
|
||||
}
|
||||
|
||||
.caption {
|
||||
@ -324,6 +333,7 @@
|
||||
text-align: center;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.thumbnails {
|
||||
@ -341,9 +351,7 @@
|
||||
.thumbnail-item {
|
||||
--ring-color: transparent;
|
||||
position: relative;
|
||||
box-shadow:
|
||||
0 0 0 2px var(--ring-color),
|
||||
var(--shadow-drop);
|
||||
box-shadow: 0 0 0 2px var(--ring-color), var(--shadow-drop);
|
||||
border: 1px solid var(--border-color-primary);
|
||||
border-radius: var(--button-small-radius);
|
||||
background: var(--background-fill-secondary);
|
||||
|
@ -5,13 +5,12 @@
|
||||
export let selected = false;
|
||||
</script>
|
||||
|
||||
<!-- TODO: fix -->
|
||||
<!-- svelte-ignore a11y-missing-attribute -->
|
||||
<img
|
||||
src={samples_dir + value}
|
||||
class:table={type === "table"}
|
||||
class:gallery={type === "gallery"}
|
||||
class:selected
|
||||
alt=""
|
||||
/>
|
||||
|
||||
<style>
|
||||
|
@ -69,12 +69,12 @@
|
||||
if (source === "webcam" && initial) {
|
||||
value = {
|
||||
image: detail,
|
||||
mask: null
|
||||
mask: null,
|
||||
};
|
||||
} else {
|
||||
value = {
|
||||
image: typeof value === "string" ? value : value?.image || null,
|
||||
mask: detail
|
||||
mask: detail,
|
||||
};
|
||||
}
|
||||
} else if (
|
||||
@ -278,7 +278,7 @@
|
||||
<!-- svelte-ignore a11y-no-noninteractive-element-interactions-->
|
||||
<img
|
||||
src={value.image || value}
|
||||
alt="hello"
|
||||
alt=""
|
||||
class:webcam={source === "webcam" && mirror_webcam}
|
||||
class:selectable
|
||||
on:click={handle_click}
|
||||
|
@ -32,7 +32,7 @@
|
||||
try {
|
||||
stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: true,
|
||||
audio: include_audio
|
||||
audio: include_audio,
|
||||
});
|
||||
video_source.srcObject = stream;
|
||||
video_source.muted = true;
|
||||
@ -81,7 +81,7 @@
|
||||
dispatch("capture", {
|
||||
data: e.target.result,
|
||||
name: "sample." + mimeType.substring(6),
|
||||
is_example: false
|
||||
is_example: false,
|
||||
});
|
||||
dispatch("stop_recording");
|
||||
}
|
||||
@ -102,7 +102,7 @@
|
||||
return;
|
||||
}
|
||||
media_recorder = new MediaRecorder(stream, {
|
||||
mimeType: mimeType
|
||||
mimeType: mimeType,
|
||||
});
|
||||
media_recorder.addEventListener("dataavailable", function (e) {
|
||||
recorded_blobs.push(e.data);
|
||||
@ -125,21 +125,25 @@
|
||||
|
||||
<div class="wrap">
|
||||
<!-- svelte-ignore a11y-media-has-caption -->
|
||||
<!-- need to suppress for video streaming https://github.com/sveltejs/svelte/issues/5967 -->
|
||||
<video bind:this={video_source} class:flip={mirror_webcam} />
|
||||
{#if !streaming}
|
||||
<button on:click={mode === "image" ? take_picture : take_recording}>
|
||||
<button
|
||||
on:click={mode === "image" ? take_picture : take_recording}
|
||||
aria-label={mode === "image" ? "capture photo" : "start recording"}
|
||||
>
|
||||
{#if mode === "video"}
|
||||
{#if recording}
|
||||
<div class="icon">
|
||||
<div class="icon" title="stop recording">
|
||||
<Square />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="icon">
|
||||
<div class="icon" title="start recording">
|
||||
<Circle />
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="icon">
|
||||
<div class="icon" title="capture photo">
|
||||
<Camera />
|
||||
</div>
|
||||
{/if}
|
||||
|
@ -58,16 +58,9 @@
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- TODO: fix -->
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<!-- svelte-ignore a11y-no-noninteractive-element-interactions-->
|
||||
<img
|
||||
src={value}
|
||||
alt=""
|
||||
class:selectable
|
||||
on:click={handle_click}
|
||||
loading="lazy"
|
||||
/>
|
||||
<button on:click={handle_click}>
|
||||
<img src={value} alt="" class:selectable loading="lazy" />
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
|
@ -40,13 +40,19 @@
|
||||
</script>
|
||||
|
||||
{#if value && value !== '""' && !is_empty(value)}
|
||||
<button on:click={handle_copy}>
|
||||
<button
|
||||
on:click={handle_copy}
|
||||
title="copy"
|
||||
class={copied ? "" : "copy-text"}
|
||||
aria-roledescription={copied ? "Copied value" : "Copy value"}
|
||||
aria-label={copied ? "Copied" : "Copy"}
|
||||
>
|
||||
{#if copied}
|
||||
<span in:fade={{ duration: 300 }}>
|
||||
<Check />
|
||||
</span>
|
||||
{:else}
|
||||
<span class="copy-text"><Copy /></span>
|
||||
<Copy />
|
||||
{/if}
|
||||
</button>
|
||||
<div class="json-holder">
|
||||
|
@ -48,8 +48,8 @@
|
||||
depth={depth + 1}
|
||||
key={i}
|
||||
/><!--
|
||||
-->{#if i !== Object.keys(value).length - 1}<!--
|
||||
-->,
|
||||
-->{#if i !== Object.keys(value).length - 1}<!--
|
||||
-->,
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
|
@ -14,21 +14,18 @@
|
||||
</script>
|
||||
|
||||
<div class="container">
|
||||
<div
|
||||
<h2
|
||||
class="output-class"
|
||||
data-testid="label-output-value"
|
||||
class:no-confidence={!("confidences" in value)}
|
||||
style:background-color={color || "transparent"}
|
||||
>
|
||||
{value.label}
|
||||
</div>
|
||||
</h2>
|
||||
|
||||
<!-- TODO: fix -->
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events-->
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions-->
|
||||
{#if typeof value === "object" && value.confidences}
|
||||
{#each value.confidences as confidence_set, i}
|
||||
<div
|
||||
<button
|
||||
class="confidence-set group"
|
||||
data-testid={`${confidence_set.label}-confidence-set`}
|
||||
class:selectable
|
||||
@ -37,18 +34,31 @@
|
||||
}}
|
||||
>
|
||||
<div class="inner-wrap">
|
||||
<div class="bar" style="width: {confidence_set.confidence * 100}%" />
|
||||
<div class="label">
|
||||
<div class="text">{confidence_set.label}</div>
|
||||
<meter
|
||||
aria-labelledby="meter-text"
|
||||
class="bar"
|
||||
min="0"
|
||||
max="100"
|
||||
style="width: {confidence_set.confidence *
|
||||
100}%; background: var(--stat-background-fill);
|
||||
"
|
||||
value={confidence_set.confidence * 100}
|
||||
aria-label={Math.round(confidence_set.confidence * 100) + "%"}
|
||||
/>
|
||||
|
||||
<dl class="label">
|
||||
<dt id={`meter-text-${confidence_set.label}`} class="text">
|
||||
{confidence_set.label}
|
||||
</dt>
|
||||
{#if value.confidences}
|
||||
<div class="line" />
|
||||
<div class="confidence">
|
||||
<dd class="confidence">
|
||||
{Math.round(confidence_set.confidence * 100)}%
|
||||
</div>
|
||||
</dd>
|
||||
{/if}
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
@ -75,6 +85,7 @@
|
||||
color: var(--body-text-color);
|
||||
line-height: var(--line-none);
|
||||
font-family: var(--font-mono);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.confidence-set:last-child {
|
||||
@ -83,9 +94,13 @@
|
||||
|
||||
.inner-wrap {
|
||||
flex: 1 1 0%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.bar {
|
||||
appearance: none;
|
||||
align-self: flex-start;
|
||||
margin-bottom: var(--size-1);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--stat-background-fill);
|
||||
@ -105,6 +120,10 @@
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.confidence-set:focus .label {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.text {
|
||||
line-height: var(--line-md);
|
||||
}
|
||||
@ -120,7 +139,4 @@
|
||||
margin-left: auto;
|
||||
text-align: right;
|
||||
}
|
||||
.selectable {
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
|
@ -46,6 +46,7 @@
|
||||
<label class="block" class:container>
|
||||
<BlockTitle {show_label} {info}>{label}</BlockTitle>
|
||||
<input
|
||||
aria-label={label}
|
||||
type="number"
|
||||
bind:value
|
||||
min={minimum}
|
||||
|
@ -116,7 +116,7 @@
|
||||
`https://cdn.pydata.org/bokeh/release/bokeh-widgets-${bokeh_version}.min.js`,
|
||||
`https://cdn.pydata.org/bokeh/release/bokeh-tables-${bokeh_version}.min.js`,
|
||||
`https://cdn.pydata.org/bokeh/release/bokeh-gl-${bokeh_version}.min.js`,
|
||||
`https://cdn.pydata.org/bokeh/release/bokeh-api-${bokeh_version}.min.js`
|
||||
`https://cdn.pydata.org/bokeh/release/bokeh-api-${bokeh_version}.min.js`,
|
||||
];
|
||||
|
||||
function load_plugins(): HTMLScriptElement[] {
|
||||
@ -201,8 +201,7 @@
|
||||
</div>
|
||||
{:else if type == "matplotlib"}
|
||||
<div data-testid={"matplotlib"} class="matplotlib layout">
|
||||
<!-- svelte-ignore a11y-missing-attribute -->
|
||||
<img src={plot} />
|
||||
<img src={plot} alt={`${value.chart} plot visualising provided data`} />
|
||||
</div>
|
||||
{:else}
|
||||
<Empty unpadded_box={true} size="large"><PlotIcon /></Empty>
|
||||
|
@ -81,6 +81,7 @@
|
||||
label:focus {
|
||||
background: var(--checkbox-label-background-fill-focus);
|
||||
}
|
||||
|
||||
label.selected {
|
||||
background: var(--checkbox-label-background-fill-selected);
|
||||
color: var(--checkbox-label-text-color-selected);
|
||||
@ -101,8 +102,7 @@
|
||||
}
|
||||
|
||||
input:checked,
|
||||
input:checked:hover,
|
||||
input:checked:focus {
|
||||
input:checked:hover {
|
||||
border-color: var(--checkbox-border-color-selected);
|
||||
background-image: var(--radio-circle);
|
||||
background-color: var(--checkbox-background-color-selected);
|
||||
@ -118,6 +118,12 @@
|
||||
background-color: var(--checkbox-background-color-focus);
|
||||
}
|
||||
|
||||
input:checked:focus {
|
||||
border-color: var(--checkbox-border-color-focus);
|
||||
background-image: var(--radio-circle);
|
||||
background-color: var(--checkbox-background-color-selected);
|
||||
}
|
||||
|
||||
input[disabled],
|
||||
.disabled {
|
||||
cursor: not-allowed;
|
||||
|
@ -62,7 +62,12 @@ test("Slider Default Value And Label rendered", async ({ mount }) => {
|
||||
}
|
||||
});
|
||||
await expect(component).toContainText("My Slider");
|
||||
await expect(component.getByLabel("My Slider")).toHaveValue("3");
|
||||
|
||||
expect(
|
||||
component.getByRole("spinbutton", {
|
||||
name: "My Slider"
|
||||
})
|
||||
).toHaveValue("3");
|
||||
});
|
||||
|
||||
test("Slider respects show_label", async ({ mount, page }) => {
|
||||
@ -100,11 +105,28 @@ test("Slider Maximum/Minimum values", async ({ mount, page }) => {
|
||||
}
|
||||
}
|
||||
});
|
||||
const slider = component.getByLabel("My Slider");
|
||||
await changeSlider(page, slider, slider, 1);
|
||||
await expect(component.getByLabel("My Slider")).toHaveValue("10");
|
||||
await changeSlider(page, slider, slider, 0);
|
||||
await expect(component.getByLabel("My Slider")).toHaveValue("0");
|
||||
|
||||
const sliderNumberInput = component.getByRole("spinbutton", {
|
||||
name: "My Slider"
|
||||
});
|
||||
|
||||
await expect(sliderNumberInput).toHaveValue("3");
|
||||
|
||||
await sliderNumberInput.press("ArrowUp");
|
||||
|
||||
await expect(sliderNumberInput).toHaveValue("4");
|
||||
|
||||
const sliderRangeInput = component.getByRole("slider");
|
||||
|
||||
await sliderRangeInput.focus();
|
||||
|
||||
sliderRangeInput.press("ArrowRight");
|
||||
|
||||
await expect(sliderNumberInput).toHaveValue("5");
|
||||
|
||||
changeSlider(page, sliderRangeInput, sliderRangeInput, 2);
|
||||
|
||||
await expect(sliderNumberInput).toHaveValue("10");
|
||||
});
|
||||
|
||||
test("Slider Change event", async ({ mount, page }) => {
|
||||
@ -133,10 +155,17 @@ test("Slider Change event", async ({ mount, page }) => {
|
||||
}
|
||||
});
|
||||
|
||||
const slider = page.getByLabel("Slider");
|
||||
const sliderNumberInput = component.getByRole("spinbutton", {
|
||||
name: "My Slider"
|
||||
});
|
||||
|
||||
await changeSlider(page, slider, slider, 0.7);
|
||||
await expect(component.getByLabel("My Slider")).toHaveValue("7");
|
||||
const sliderRangeInput = component.getByRole("slider");
|
||||
|
||||
await expect(sliderNumberInput).toHaveValue("3");
|
||||
|
||||
await changeSlider(page, sliderRangeInput, sliderRangeInput, 0.7);
|
||||
|
||||
await expect(sliderNumberInput).toHaveValue("7");
|
||||
|
||||
// More than one change event and one release event.
|
||||
await expect(events.change).toBeGreaterThanOrEqual(1);
|
||||
|
@ -66,6 +66,7 @@
|
||||
</label>
|
||||
|
||||
<input
|
||||
aria-label={`number input for ${label}`}
|
||||
data-testid="number-input"
|
||||
type="number"
|
||||
bind:value
|
||||
@ -91,6 +92,7 @@
|
||||
{step}
|
||||
{disabled}
|
||||
on:pointerup={handle_release}
|
||||
aria-label={`range slider for ${label}`}
|
||||
/>
|
||||
|
||||
<style>
|
||||
|
@ -3,7 +3,7 @@
|
||||
beforeUpdate,
|
||||
afterUpdate,
|
||||
createEventDispatcher,
|
||||
tick
|
||||
tick,
|
||||
} from "svelte";
|
||||
import { BlockTitle } from "@gradio/atoms";
|
||||
import { Copy, Check } from "@gradio/icons";
|
||||
@ -92,7 +92,7 @@
|
||||
const text = target.value;
|
||||
const index: [number, number] = [
|
||||
target.selectionStart as number,
|
||||
target.selectionEnd as number
|
||||
target.selectionEnd as number,
|
||||
];
|
||||
dispatch("select", { value: text.substring(...index), index: index });
|
||||
}
|
||||
@ -154,7 +154,7 @@
|
||||
resize({ target: _el });
|
||||
|
||||
return {
|
||||
destroy: () => _el.removeEventListener("input", resize)
|
||||
destroy: () => _el.removeEventListener("input", resize),
|
||||
};
|
||||
}
|
||||
</script>
|
||||
@ -217,9 +217,17 @@
|
||||
{:else}
|
||||
{#if show_label && show_copy_button}
|
||||
{#if copied}
|
||||
<button in:fade={{ duration: 300 }}><Check /></button>
|
||||
<button
|
||||
in:fade={{ duration: 300 }}
|
||||
aria-label="Copied"
|
||||
aria-roledescription="Text copied"><Check /></button
|
||||
>
|
||||
{:else}
|
||||
<button on:click={handle_copy} class="copy-text"><Copy /></button>
|
||||
<button
|
||||
on:click={handle_copy}
|
||||
aria-label="Copy"
|
||||
aria-roledescription="Copy text"><Copy /></button
|
||||
>
|
||||
{/if}
|
||||
{/if}
|
||||
<textarea
|
||||
|
@ -1,6 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from "svelte";
|
||||
import type { FileData } from "./types";
|
||||
import { blobToBase64 } from "./utils";
|
||||
|
||||
export let filetype: string | null = null;
|
||||
@ -39,7 +38,7 @@
|
||||
if (include_file_metadata) {
|
||||
var file_metadata: { name: string; size: number }[] = _files.map((f) => ({
|
||||
name: f.name,
|
||||
size: f.size
|
||||
size: f.size,
|
||||
}));
|
||||
}
|
||||
var load_file_data = [];
|
||||
@ -53,13 +52,13 @@
|
||||
if (parse_to_data_url) {
|
||||
load_file_data = file_data.map((data, i) => ({
|
||||
data,
|
||||
...file_metadata[i]
|
||||
...file_metadata[i],
|
||||
}));
|
||||
} else {
|
||||
load_file_data = file_data.map((data, i) => ({
|
||||
data: "",
|
||||
blob: data,
|
||||
...file_metadata[i]
|
||||
...file_metadata[i],
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
@ -85,10 +84,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- TODO: fix -->
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div
|
||||
<button
|
||||
class:center
|
||||
class:boundedheight
|
||||
class:flex
|
||||
@ -114,17 +110,18 @@
|
||||
webkitdirectory={file_count === "directory" || undefined}
|
||||
mozdirectory={file_count === "directory" || undefined}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<style>
|
||||
div {
|
||||
button {
|
||||
cursor: pointer;
|
||||
width: var(--size-full);
|
||||
height: var(--size-full);
|
||||
}
|
||||
|
||||
.center {
|
||||
text-align: center;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
.flex {
|
||||
display: flex;
|
||||
|
Loading…
x
Reference in New Issue
Block a user