diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml new file mode 100644 index 00000000..5275ddef --- /dev/null +++ b/.github/workflows/playwright.yml @@ -0,0 +1,48 @@ +name: Playwright + +on: + push: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + browser: [chromium, firefox, webkit] + steps: + - uses: actions/checkout@v6 + - name: Set up Python 3.14 + uses: actions/setup-python@v6 + with: + python-version: "3.14" + allow-prereleases: true + cache: pip + cache-dependency-path: pyproject.toml + - name: Cache uv + uses: actions/cache@v5 + with: + path: ~/.cache/uv + key: ${{ runner.os }}-py3.14-uv-${{ hashFiles('pyproject.toml') }} + restore-keys: | + ${{ runner.os }}-py3.14-uv- + - name: Cache Playwright browsers + uses: actions/cache@v5 + with: + path: ~/.cache/ms-playwright/ + key: ${{ runner.os }}-playwright-${{ matrix.browser }}-${{ hashFiles('pyproject.toml') }} + restore-keys: | + ${{ runner.os }}-playwright-${{ matrix.browser }}- + - name: Install uv + run: python -m pip install uv + - name: Install dependencies + run: uv sync --group dev --group playwright + - name: Install ${{ matrix.browser }} + run: uv run --group dev --group playwright playwright install --with-deps ${{ matrix.browser }} + - name: Run Playwright tests + run: uv run --group dev --group playwright pytest tests/test_playwright.py --playwright --browser ${{ matrix.browser }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a1b2e9d2..9e47db6f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -9,6 +9,7 @@ jobs: test: runs-on: ubuntu-latest strategy: + fail-fast: false matrix: python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] steps: diff --git a/.gitignore b/.gitignore index 12acd87e..8c058692 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ build-metadata.json datasets.json +.playwright-mcp + scratchpad .vscode @@ -131,4 +133,4 @@ tests/*.dylib tests/*.so tests/*.dll -.idea \ No newline at end of file +.idea diff --git a/Justfile b/Justfile index 657881be..5fcd9afd 100644 --- a/Justfile +++ b/Justfile @@ -11,6 +11,22 @@ export DATASETTE_SECRET := "not_a_secret" @test *options: init uv run pytest -n auto {{options}} +# Install Playwright browser support, Chromium by default +@playwright-install browser="chromium": + uv run --group playwright playwright install {{browser}} + +# Install all Playwright browsers used by the test suite +@playwright-install-all: + uv run --group playwright playwright install chromium firefox webkit + +# Run Playwright tests, Chromium by default +@playwright browser="chromium" *options: + uv run --group playwright pytest tests/test_playwright.py --playwright --browser {{browser}} {{options}} + +# Run Playwright tests against all supported browsers +@playwright-all *options: + uv run --group playwright pytest tests/test_playwright.py --playwright --browser chromium --browser firefox --browser webkit {{options}} + @codespell: uv run codespell README.md --ignore-words docs/codespell-ignore-words.txt uv run codespell docs/*.rst --ignore-words docs/codespell-ignore-words.txt diff --git a/datasette/app.py b/datasette/app.py index b683969a..9f0c8397 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -2,7 +2,7 @@ from __future__ import annotations import asyncio import contextvars -from typing import TYPE_CHECKING, Any, Dict, Iterable, List +from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Sequence if TYPE_CHECKING: from datasette.permissions import Resource @@ -47,9 +47,14 @@ from .views import Context from .views.database import ( database_download, DatabaseView, - TableCreateView, QueryView, ) +from .views.table_create_alter import ( + DatabaseForeignKeyTargetsView, + TableAlterView, + TableCreateView, + TableForeignKeySuggestionsView, +) from .views.execute_write import ExecuteWriteAnalyzeView, ExecuteWriteView from .views.stored_queries import ( QueryCreateAnalyzeView, @@ -66,6 +71,7 @@ from .views.index import IndexView from .views.special import ( JsonDataView, PatternPortfolioView, + AutocompleteDebugView, AuthTokenView, ApiExplorerView, CreateTokenView, @@ -82,10 +88,12 @@ from .views.special import ( TableSchemaView, ) from .views.table import ( + TableAutocompleteView, TableInsertView, TableUpsertView, TableSetColumnTypeView, TableDropView, + TableFragmentView, table_view, ) from .views.row import RowView, RowDeleteView, RowUpdateView @@ -291,6 +299,15 @@ DEFAULT_NOT_SET = object() ResourcesSQL = collections.namedtuple("ResourcesSQL", ("sql", "params")) +def _permission_cache_key(actor, action, parent, child): + # Key on the full serialized actor so actors differing in any field + # (e.g. token restrictions) never share cache entries + actor_key = ( + json.dumps(actor, sort_keys=True, default=repr) if actor is not None else None + ) + return (actor_key, action, parent, child) + + async def favicon(request, send): await asgi_send_file( send, @@ -327,6 +344,8 @@ TEMPLATE_BASE_CONTEXT = { "display_actor": "Function returning a display string for an actor dictionary", "show_logout": "True if the logout link should be shown in the navigation menu", "app_css_hash": "Hash of Datasette's app.css contents, used for cache busting", + "edit_tools_js_hash": "Hash of Datasette's edit-tools.js contents, used for cache busting", + "table_js_hash": "Hash of Datasette's table.js contents, used for cache busting", "zip": "Python's zip() builtin, made available to template logic", "body_scripts": "List of script blocks for the page body contributed by plugins", "format_bytes": "Function that formats a number of bytes as a human-readable size", @@ -639,9 +658,12 @@ class Datasette: return action return None - async def refresh_schemas(self): + async def refresh_schemas(self, *, force=False): # Throttle schema refreshes to at most once per second - if time.monotonic() - getattr(self, "_last_schema_refresh", 0) < 1.0: + if ( + not force + and time.monotonic() - getattr(self, "_last_schema_refresh", 0) < 1.0 + ): return self._last_schema_refresh = time.monotonic() if self._refresh_schemas_lock.locked(): @@ -1843,46 +1865,124 @@ class Datasette: # For global actions, resource can be omitted: can_debug = await datasette.allowed(action="permissions-debug", actor=actor) """ - from datasette.utils.actions_sql import check_permission_for_resource + results = await self.allowed_many( + actions=[action], resource=resource, actor=actor + ) + return results[action] - # For global actions, resource remains None + async def allowed_many( + self, + *, + actions: Sequence[str], + resource: "Resource" = None, + actor: dict | None = None, + ) -> dict[str, bool]: + """ + Check several actions against one resource for one actor. - # Check if this action has also_requires - if so, check that action first - action_obj = self.actions.get(action) - if action_obj and action_obj.also_requires: - # Must have the required action first - if not await self.allowed( - action=action_obj.also_requires, - resource=resource, + Resolves every action (plus any also_requires dependencies) with a + single internal database query, instead of one or two queries per + action. Results are stored in the request-scoped permission cache, + so subsequent datasette.allowed() calls for the same checks within + the same request are served from the cache. + + Example: + from datasette.resources import TableResource + results = await datasette.allowed_many( + actions=["edit-schema", "drop-table", "insert-row"], + resource=TableResource(database="data", table="exercise"), actor=actor, - ): - return False + ) + # {"edit-schema": True, "drop-table": True, "insert-row": False} + """ + from datasette.utils.actions_sql import check_permissions_for_actions + from datasette.permissions import ( + _permission_check_cache, + _skip_permission_checks, + ) # For global actions, resource is None parent = resource.parent if resource else None child = resource.child if resource else None - result = await check_permission_for_resource( - datasette=self, - actor=actor, - action=action, - parent=parent, - child=child, - ) + # Expand also_requires dependencies (transitively) so that each + # dependency is resolved within the same batch + expanded = [] - # Log the permission check for debugging - self._permission_checks.append( - PermissionCheck( - when=datetime.datetime.now(datetime.timezone.utc).isoformat(), + def add_action(name): + if name in expanded: + return + action_obj = self.actions.get(name) + if action_obj is None: + raise ValueError(f"Unknown action: {name}") + expanded.append(name) + if action_obj.also_requires: + add_action(action_obj.also_requires) + + requested = list(dict.fromkeys(actions)) + for name in requested: + add_action(name) + + # Consult the request-scoped cache, unless permission checks are + # being skipped (skip-mode verdicts must never be cached) + skip = _skip_permission_checks.get() + cache = None if skip else _permission_check_cache.get() + + final = {} + to_check = [] + for name in expanded: + if cache is not None: + key = _permission_cache_key(actor, name, parent, child) + if key in cache: + final[name] = cache[key] + continue + to_check.append(name) + + raw = {} + if to_check: + raw = await check_permissions_for_actions( + datasette=self, actor=actor, - action=action, + actions=to_check, parent=parent, child=child, - result=result, ) - ) - return result + def resolve(name): + # final verdict = own rules AND verdict of also_requires chain + if name in final: + return final[name] + result = raw[name] + action_obj = self.actions.get(name) + if result and action_obj.also_requires: + result = resolve(action_obj.also_requires) + final[name] = result + return result + + for name in expanded: + resolve(name) + + # Cache the freshly computed checks + if cache is not None: + for name in to_check: + cache[_permission_cache_key(actor, name, parent, child)] = final[name] + + # Log every check (including cache hits) for the debug page, + # dependencies before the actions that required them + when = datetime.datetime.now(datetime.timezone.utc).isoformat() + for name in reversed(expanded): + self._permission_checks.append( + PermissionCheck( + when=when, + actor=actor, + action=name, + parent=parent, + child=child, + result=final[name], + ) + ) + + return {name: final[name] for name in requested} async def ensure_permission( self, @@ -1955,6 +2055,11 @@ class Datasette: other_table = fk["other_table"] other_column = fk["other_column"] + if other_column is None: + other_pks = await db.primary_keys(other_table) + if len(other_pks) != 1: + return {} + other_column = other_pks[0] visible, _ = await self.check_visibility( actor, action="view-table", @@ -2257,6 +2362,8 @@ class Datasette: and "ds_actor" in request.cookies and request.actor, "app_css_hash": self.app_css_hash(), + "edit_tools_js_hash": self.static_hash("edit-tools.js"), + "table_js_hash": self.static_hash("table.js"), "zip": zip, "body_scripts": body_scripts, "format_bytes": format_bytes, @@ -2481,6 +2588,10 @@ class Datasette: wrap_view(PatternPortfolioView, self), r"/-/patterns$", ) + add_route( + AutocompleteDebugView.as_view(self), + r"/-/debug/autocomplete$", + ) add_route( wrap_view(database_download, self), r"/(?P[^\/\.]+)\.db$", @@ -2490,6 +2601,10 @@ class Datasette: r"/(?P[^\/\.]+)(\.(?P\w+))?$", ) add_route(TableCreateView.as_view(self), r"/(?P[^\/\.]+)/-/create$") + add_route( + DatabaseForeignKeyTargetsView.as_view(self), + r"/(?P[^\/\.]+)/-/foreign-key-targets$", + ) add_route( QueryListView.as_view(self), r"/(?P[^\/\.]+)/-/queries(\.(?Pjson))?$", @@ -2554,10 +2669,26 @@ class Datasette: TableUpsertView.as_view(self), r"/(?P[^\/\.]+)/(?P[^\/\.]+)/-/upsert$", ) + add_route( + TableAlterView.as_view(self), + r"/(?P[^\/\.]+)/(?P
[^\/\.]+)/-/alter$", + ) + add_route( + TableForeignKeySuggestionsView.as_view(self), + r"/(?P[^\/\.]+)/(?P
[^\/\.]+)/-/foreign-key-suggestions$", + ) add_route( TableSetColumnTypeView.as_view(self), r"/(?P[^\/\.]+)/(?P
[^\/\.]+)/-/set-column-type$", ) + add_route( + TableFragmentView.as_view(self), + r"/(?P[^\/\.]+)/(?P
[^\/\.]+)/-/fragment$", + ) + add_route( + TableAutocompleteView.as_view(self), + r"/(?P[^\/\.]+)/(?P
[^\/\.]+)/-/autocomplete$", + ) add_route( TableDropView.as_view(self), r"/(?P[^\/\.]+)/(?P
[^\/\.]+)/-/drop$", @@ -2644,7 +2775,16 @@ class DatasetteRouter: if raw_path: path = raw_path.decode("ascii") path = path.partition("?")[0] - return await self.route_path(scope, receive, send, path) + # Give each request a fresh permission check cache, so repeated + # datasette.allowed() checks within the request are memoized but + # results never persist beyond it + from datasette.permissions import _permission_check_cache + + cache_token = _permission_check_cache.set({}) + try: + return await self.route_path(scope, receive, send, path) + finally: + _permission_check_cache.reset(cache_token) async def route_path(self, scope, receive, send, path): # Strip off base_url if present before routing diff --git a/datasette/column_types.py b/datasette/column_types.py index 7320e1d6..11a14ec0 100644 --- a/datasette/column_types.py +++ b/datasette/column_types.py @@ -6,19 +6,17 @@ class SQLiteType(Enum): INTEGER = "INTEGER" REAL = "REAL" BLOB = "BLOB" - NULL = "NULL" + NUMERIC = "NUMERIC" @classmethod - def from_declared_type(cls, declared_type: str | None) -> "SQLiteType | None": + def from_declared_type(cls, declared_type: str | None) -> "SQLiteType": if declared_type is None: - return cls.NULL + return cls.BLOB normalized = declared_type.strip().upper() if not normalized: - return cls.NULL + return cls.BLOB - if normalized == cls.NULL.value: - return cls.NULL if "INT" in normalized: return cls.INTEGER if any(token in normalized for token in ("CHAR", "CLOB", "TEXT")): @@ -31,7 +29,7 @@ class SQLiteType(Enum): ): return cls.REAL - return None + return cls.NUMERIC class ColumnType: diff --git a/datasette/database.py b/datasette/database.py index 6cd5d11e..e7fe1ed9 100644 --- a/datasette/database.py +++ b/datasette/database.py @@ -829,10 +829,10 @@ def _apply_write_wrapper(fn, wrapper_factory, track_event): # Execute the actual write try: result = fn(conn) - except Exception: + except Exception as e: # Throw exception into generator so it can handle it try: - gen.throw(*sys.exc_info()) + gen.throw(e) except StopIteration: pass # Re-raise the original exception diff --git a/datasette/default_column_types.py b/datasette/default_column_types.py index 24493994..f90a733e 100644 --- a/datasette/default_column_types.py +++ b/datasette/default_column_types.py @@ -76,6 +76,12 @@ class JsonColumnType(ColumnType): return None +class TextareaColumnType(ColumnType): + name = "textarea" + description = "Multiline text" + sqlite_types = (SQLiteType.TEXT,) + + @hookimpl def register_column_types(datasette): - return [UrlColumnType, EmailColumnType, JsonColumnType] + return [UrlColumnType, EmailColumnType, JsonColumnType, TextareaColumnType] diff --git a/datasette/default_debug_menu.py b/datasette/default_debug_menu.py index 6127b2a6..8ea3c287 100644 --- a/datasette/default_debug_menu.py +++ b/datasette/default_debug_menu.py @@ -37,6 +37,11 @@ DEBUG_MENU_ITEMS = ( "Debug allow rules", "Explore how allow blocks match actors against permission rules.", ), + ( + "/-/debug/autocomplete", + "Debug autocomplete", + "Try out table autocomplete against a detected label column.", + ), ( "/-/threads", "Debug threads", diff --git a/datasette/default_table_actions.py b/datasette/default_table_actions.py new file mode 100644 index 00000000..e41434ef --- /dev/null +++ b/datasette/default_table_actions.py @@ -0,0 +1,29 @@ +from datasette import hookimpl +from datasette.resources import TableResource + + +@hookimpl +def table_actions(datasette, actor, database, table, request): + async def inner(): + db = datasette.get_database(database) + if not db.is_mutable: + return [] + if not await datasette.allowed( + action="alter-table", + resource=TableResource(database=database, table=table), + actor=actor, + ): + return [] + return [ + { + "type": "button", + "label": "Alter table", + "description": "Change columns and primary key for this table.", + "attrs": { + "aria-label": "Alter table {}".format(table), + "data-table-action": "alter-table", + }, + } + ] + + return inner diff --git a/datasette/hookspecs.py b/datasette/hookspecs.py index dcd502af..7c56f882 100644 --- a/datasette/hookspecs.py +++ b/datasette/hookspecs.py @@ -159,32 +159,32 @@ def jump_items_sql(datasette, actor, request): @hookspec def row_actions(datasette, actor, request, database, table, row): - """Links for the row actions menu""" + """Items for the row actions menu""" @hookspec def table_actions(datasette, actor, database, table, request): - """Links for the table actions menu""" + """Items for the table actions menu""" @hookspec def view_actions(datasette, actor, database, view, request): - """Links for the view actions menu""" + """Items for the view actions menu""" @hookspec def query_actions(datasette, actor, database, query_name, request, sql, params): - """Links for the query and stored query actions menu""" + """Items for the query and stored query actions menu""" @hookspec def database_actions(datasette, actor, database, request): - """Links for the database actions menu""" + """Items for the database actions menu""" @hookspec def homepage_actions(datasette, actor, request): - """Links for the homepage actions menu""" + """Items for the homepage actions menu""" @hookspec diff --git a/datasette/permissions.py b/datasette/permissions.py index a9a3cc7c..786dc026 100644 --- a/datasette/permissions.py +++ b/datasette/permissions.py @@ -8,6 +8,14 @@ _skip_permission_checks = contextvars.ContextVar( "skip_permission_checks", default=False ) +# Request-scoped cache of permission check results. The ASGI router sets +# this to a fresh dict at the start of each request, so cached verdicts +# never outlive a request or leak between actors. Keys are +# (actor_json, action, parent, child) tuples, values are booleans. +_permission_check_cache: contextvars.ContextVar[dict | None] = contextvars.ContextVar( + "permission_check_cache", default=None +) + class SkipPermissions: """Context manager to temporarily skip permission checks. diff --git a/datasette/plugins.py b/datasette/plugins.py index f0fbc7f8..ae2cb17d 100644 --- a/datasette/plugins.py +++ b/datasette/plugins.py @@ -31,6 +31,7 @@ DEFAULT_PLUGINS = ( "datasette.default_debug_menu", "datasette.default_jump_items", "datasette.default_database_actions", + "datasette.default_table_actions", "datasette.default_query_actions", "datasette.handle_exception", "datasette.forbidden", diff --git a/datasette/static/app.css b/datasette/static/app.css index 6d675d9f..ce800f61 100644 --- a/datasette/static/app.css +++ b/datasette/static/app.css @@ -609,9 +609,6 @@ button.core[type=button] { border-color: #007bff; } -.filter-row { - margin-bottom: 0.6em; -} .search-row { margin-bottom: 1.8em; } @@ -623,72 +620,239 @@ button.core[type=button] { width: 80px; } -.select-wrapper { - border: 1px solid #ccc; - width: 120px; - border-radius: 3px; +.filters .search-row { + box-sizing: border-box; + display: grid; + grid-template-columns: max-content minmax(16rem, 1fr); + align-items: center; + gap: 0.6rem; + margin: 0 0 0.45rem; + padding-right: var(--filter-two-icon-space); +} + +.filters .search-row label { + width: auto; padding: 0; - background-color: #fafafa; - position: relative; + color: var(--filter-muted); + font-size: 0.875rem; + font-weight: 600; +} + +.filters { + --filter-ink: #0f0f0f; + --filter-paper: #eef6ff; + --filter-muted: #6b6b6b; + --filter-rule: #d8e6f5; + --filter-accent: #1a56db; + --filter-control-border: #bfccd9; + --filter-control-height: 2.125rem; + --filter-control-gap: 0.4rem; + --filter-row-icon-size: 2rem; + --filter-two-icon-space: calc( + (2 * var(--filter-row-icon-size)) + (2 * var(--filter-control-gap)) + ); + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 0.55rem; + max-width: 760px; + margin: 0 0 1rem; + padding: 0.75rem; + border: 1px solid var(--filter-rule); + border-radius: 8px; + background: var(--filter-paper); + color: var(--filter-ink); + font-size: 0.875rem; +} + +.filters .filter-row { + margin-bottom: 0; +} + +.filters .filter-controls-row { + display: grid; + min-width: 0; + width: 100%; + max-width: 100%; + box-sizing: border-box; + grid-template-columns: + minmax(8rem, 0.75fr) + minmax(6.5rem, 0.5fr) + minmax(12rem, 1.2fr) + var(--filter-row-icon-size) + var(--filter-row-icon-size); + gap: var(--filter-control-gap); + align-items: center; +} + +.filters .filter-actions-row { + display: flex; + align-items: center; + justify-content: flex-start; + flex-wrap: wrap; + gap: 0.6rem; +} + +.select-wrapper { display: inline-block; - margin-right: 0.3em; -} -.select-wrapper:focus-within { - border: 1px solid black; + width: 120px; + min-width: 0; } + .select-wrapper.filter-op { width: 80px; } -.select-wrapper::after { - content: "\25BE"; - position: absolute; - top: 0px; - right: 0.4em; - color: #bbb; - pointer-events: none; - font-size: 1.2em; - padding-top: 0.16em; + +.filters .select-wrapper { + width: auto; } .select-wrapper select { - padding: 9px 8px; + box-sizing: border-box; width: 100%; - border: none; - box-shadow: none; - background: transparent; - background-image: none; - -webkit-appearance: none; - -moz-appearance: none; + height: var(--filter-control-height); + border: 1px solid var(--filter-control-border, #ccc); + border-radius: 5px; + background-color: #fff; + color: inherit; cursor: pointer; + font-family: inherit; + font-size: 0.875rem; + line-height: 1.25; + padding: 7px 8px; } -.select-wrapper select { - font-size: 1em; - font-family: Helvetica, sans-serif; -} + .select-wrapper option { font-size: 1em; font-family: Helvetica, sans-serif; } .select-wrapper select:focus { + border-color: var(--filter-accent, #000); + box-shadow: 0 0 0 2px rgba(26, 86, 219, 0.14); outline: none; } -.filters { - font-size: 0.8em; -} .filters input.filter-value { - width: 200px; - border-radius: 3px; - -webkit-appearance: none; - padding: 9px 4px; - font-size: 16px; - font-family: Helvetica, sans-serif; + box-sizing: border-box; + width: 100%; + min-width: 0; + height: var(--filter-control-height); + border: 1px solid var(--filter-control-border); + border-radius: 5px; + background: #fff; + color: inherit; + font-family: inherit; + font-size: 0.875rem; + line-height: 1.25; + padding: 7px 9px; +} + +.filters input.filter-value:focus { + border-color: var(--filter-accent); + box-shadow: 0 0 0 2px rgba(26, 86, 219, 0.14); + outline: none; +} + +.filters input[type=submit] { + border-color: var(--filter-accent); + background: var(--filter-accent); + border-radius: 5px; + font-weight: 500; + padding: 0.55rem 0.85rem; +} + +.filters input[type=submit]:hover, +.filters input[type=submit]:focus { + background: #1949b8; + border-color: #1949b8; +} + +.filters button.filter-row-icon { + display: inline-flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + width: var(--filter-row-icon-size); + height: var(--filter-row-icon-size); + min-width: var(--filter-row-icon-size); + padding: 0; + border: 1px solid var(--filter-rule); + border-radius: 5px; + background: #fff; + color: var(--filter-muted); + font-family: inherit; + font-size: 1.15rem; + font-weight: 600; + line-height: 1; +} + +.filters button.filter-row-icon[hidden] { + display: none; +} + +.filters button.filter-row-icon:focus-visible { + outline: 3px solid rgba(26, 86, 219, 0.14); + outline-offset: 2px; +} + +.filters .filter-row-remove-icon { + display: block; + height: 14px; + width: 14px; +} + +.filters button.filter-row-remove:hover, +.filters button.filter-row-remove:focus { + border-color: #c9d5e3; + background: #f8fbff; + color: var(--filter-ink); +} + +.filters button.filter-row-add { + border-color: var(--filter-accent); + background: var(--filter-accent); + color: #fff; +} + +.filters .filter-row-add-icon { + display: block; + height: 16px; + width: 16px; +} + +.filters button.filter-row-add:hover, +.filters button.filter-row-add:focus { + border-color: #1949b8; + background: #1949b8; + color: #fff; +} + +.filters button.filter-row-add:focus-visible svg { + color: #fff; + stroke: currentColor; } #_search { font-size: 16px; } +.filters #_search { + box-sizing: border-box; + width: 100%; + height: var(--filter-control-height); + border: 1px solid var(--filter-control-border); + border-radius: 5px; + background: #fff; + color: var(--filter-ink); + font: inherit; + padding: 7px 9px; +} + +.filters #_search:focus { + border-color: var(--filter-accent); + box-shadow: 0 0 0 2px rgba(26, 86, 219, 0.14); + outline: none; +} + @@ -792,9 +956,9 @@ p.zero-results { dialog.mobile-column-actions-dialog { --ink: #0f0f0f; - --paper: #f5f3ef; + --paper: #eef6ff; --muted: #6b6b6b; - --rule: #e2dfd8; + --rule: #d8e6f5; --accent: #1a56db; --card: #ffffff; border: none; @@ -1020,9 +1184,9 @@ dialog.mobile-column-actions-dialog::backdrop { dialog.set-column-type-dialog { --ink: #0f0f0f; - --paper: #f5f3ef; + --paper: #eef6ff; --muted: #6b6b6b; - --rule: #e2dfd8; + --rule: #d8e6f5; --accent: #1a56db; --card: #ffffff; border: none; @@ -1109,7 +1273,7 @@ dialog.set-column-type-dialog::backdrop { padding: 14px 16px; border: 1px solid var(--rule); border-radius: 8px; - background: #fcfbf9; + background: #fbfdff; cursor: pointer; } @@ -1192,6 +1356,1587 @@ dialog.set-column-type-dialog::backdrop { cursor: wait; } +.row-mutation-status { + margin: 0 0 0.75rem; + padding: 8px 10px; + border-left: 4px solid #54AC8E; + background: rgba(103,201,141,0.12); + color: #222; +} + +.row-mutation-status[hidden] { + display: none; +} + +.row-mutation-status-error { + border-left-color: #D0021B; + background: rgba(208,2,27,0.12); +} + +.table-row-toolbar { + margin: 0 0 0.75rem; +} + +button.table-insert-row { + display: inline-flex; + align-items: center; + gap: 0.4rem; +} + +button.table-insert-row svg { + display: block; + flex-shrink: 0; +} + +dialog.row-delete-dialog { + --ink: #0f0f0f; + --paper: #eef6ff; + --muted: #6b6b6b; + --rule: #d8e6f5; + --accent: #1a56db; + --card: #ffffff; + border: none; + border-radius: var(--modal-border-radius, 0.75rem); + padding: 0; + margin: auto; + width: min(440px, calc(100vw - 32px)); + max-width: 95vw; + box-shadow: var(--modal-shadow, 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)); + animation: datasette-modal-slide-in var(--modal-animation-duration, 0.2s) ease-out; + overflow: hidden; + font-family: system-ui, -apple-system, sans-serif; + background: var(--card); +} + +dialog.row-delete-dialog[open] { + display: flex; + flex-direction: column; +} + +dialog.row-delete-dialog::backdrop { + background: var(--modal-backdrop-bg, rgba(0, 0, 0, 0.5)); + backdrop-filter: var(--modal-backdrop-blur, blur(4px)); + -webkit-backdrop-filter: var(--modal-backdrop-blur, blur(4px)); + animation: datasette-modal-fade-in var(--modal-animation-duration, 0.2s) ease-out; +} + +.row-delete-dialog .modal-header { + padding: 20px 24px 12px; + border-bottom: 1px solid var(--rule); + display: flex; + align-items: center; + justify-content: flex-start; + gap: 12px; + flex-shrink: 0; + min-width: 0; +} + +.row-delete-dialog .modal-title { + display: flex; + align-items: center; + gap: 0.35rem; + min-width: 0; + max-width: 100%; + font-size: 1rem; + font-weight: 600; + color: var(--ink); +} + +.row-delete-message, +.row-delete-error { + margin: 0; + padding: 16px 24px 0; +} + +.row-delete-message { + color: var(--ink); + font-size: 0.95rem; +} + +.row-delete-id { + display: inline; + padding: 2px 5px; + border: 1px solid var(--rule); + border-radius: 4px; + background: var(--paper); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.92em; + overflow-wrap: anywhere; +} + +.row-delete-error { + color: #b91c1c; + font-size: 0.9rem; +} + +.row-delete-dialog .modal-footer { + padding: 18px 20px 14px; + border-top: 1px solid var(--rule); + display: flex; + align-items: center; + justify-content: flex-end; + gap: 10px; + flex-shrink: 0; + background: var(--paper); + margin-top: 18px; +} + +.row-delete-dialog .btn { + border: none; + border-radius: 5px; + padding: 9px 20px; + font-size: 0.85rem; + font-weight: 500; + cursor: pointer; + touch-action: manipulation; + font-family: inherit; + transition: background 0.12s; +} + +.row-delete-dialog .btn-ghost { + background: transparent; + color: var(--muted); + border: 1px solid var(--rule); +} + +.row-delete-dialog .btn-ghost:hover { + background: var(--rule); + color: var(--ink); +} + +.row-delete-dialog .btn-primary { + background: var(--accent); + color: #fff; +} + +.row-delete-dialog .btn-primary:hover { + background: #1949b8; +} + +.row-delete-dialog .btn:disabled { + opacity: 0.65; + cursor: wait; +} + +dialog.row-edit-dialog { + --ink: #0f0f0f; + --paper: #eef6ff; + --muted: #6b6b6b; + --rule: #d8e6f5; + --accent: #1a56db; + --card: #ffffff; + border: none; + border-radius: var(--modal-border-radius, 0.75rem); + padding: 0; + margin: auto; + width: min(720px, calc(100vw - 32px)); + max-width: 95vw; + max-height: min(780px, calc(100vh - 32px)); + box-shadow: var(--modal-shadow, 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)); + animation: datasette-modal-slide-in var(--modal-animation-duration, 0.2s) ease-out; + overflow: hidden; + font-family: system-ui, -apple-system, sans-serif; + background: var(--card); +} + +dialog.row-edit-dialog[open] { + display: flex; + flex-direction: column; +} + +dialog.row-edit-dialog::backdrop { + background: var(--modal-backdrop-bg, rgba(0, 0, 0, 0.5)); + backdrop-filter: var(--modal-backdrop-blur, blur(4px)); + -webkit-backdrop-filter: var(--modal-backdrop-blur, blur(4px)); + animation: datasette-modal-fade-in var(--modal-animation-duration, 0.2s) ease-out; +} + +.row-edit-dialog .modal-header { + padding: 20px 24px 12px; + border-bottom: 1px solid var(--rule); + display: flex; + align-items: center; + gap: 12px; + flex-shrink: 0; + min-width: 0; +} + +.row-edit-dialog .modal-title { + display: flex; + align-items: center; + gap: 0.35rem; + min-width: 0; + max-width: 100%; + font-size: 1rem; + font-weight: 600; + color: var(--ink); +} + +.row-edit-dialog .modal-title .row-dialog-action, +.row-delete-dialog .modal-title .row-dialog-action { + flex: 0 0 auto; + white-space: nowrap; +} + +.row-edit-dialog .modal-title code, +.row-delete-dialog .modal-title code { + display: inline; + flex: 0 0 auto; + padding: 2px 5px; + border: 1px solid var(--rule); + border-radius: 4px; + background: var(--paper); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.92em; + overflow-wrap: anywhere; +} + +.row-edit-dialog .modal-title .row-dialog-label, +.row-delete-dialog .modal-title .row-dialog-label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.row-edit-form { + display: flex; + flex: 1 1 auto; + min-height: 0; + flex-direction: column; +} + +.row-edit-summary, +.row-edit-loading, +.row-edit-error { + margin: 0; + padding: 12px 24px 0; +} + +.row-edit-summary, +.row-edit-loading { + color: var(--muted); + font-size: 0.9rem; +} + +.row-edit-error { + border-left: 4px solid #b91c1c; + border-radius: 4px; + background: #fff1f1; + color: #7f1d1d; + font-size: 0.9rem; + margin: 12px 24px 0; + padding: 10px 12px; +} + +.row-edit-error:focus { + outline: 3px solid rgba(185, 28, 28, 0.18); + outline-offset: 2px; +} + +.row-edit-fields { + display: grid; + gap: 14px; + padding: 16px 24px 24px; + overflow-y: auto; +} + +.row-edit-field { + display: grid; + grid-template-columns: minmax(120px, 180px) minmax(0, 1fr); + gap: 12px; + align-items: start; +} + +.row-edit-label { + padding-top: 8px; + color: var(--ink); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.82rem; + overflow-wrap: anywhere; +} + +.row-edit-control-wrap { + display: grid; + gap: 5px; +} + +.row-edit-input { + box-sizing: border-box; + width: 100%; + min-width: 0; + border: 1px solid var(--rule); + border-radius: 5px; + padding: 8px 10px; + color: var(--ink); + background: #fff; + font: inherit; +} + +textarea.row-edit-input { + resize: vertical; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.82rem; + line-height: 1.45; +} + +.row-edit-input:focus { + border-color: var(--accent); + outline: 3px solid rgba(26, 86, 219, 0.12); +} + +.row-edit-input[aria-invalid="true"] { + border-color: #b42318; + background: #fff8f7; +} + +.row-edit-input[aria-invalid="true"]:focus { + border-color: #b42318; + outline-color: rgba(180, 35, 24, 0.16); +} + +.row-edit-input[readonly] { + color: var(--muted); + background: var(--paper); +} + +.row-edit-default { + display: grid; + grid-template-columns: minmax(0, 1fr) 7.25rem; + align-items: center; + gap: 8px; + min-width: 0; + border: 1px solid var(--rule); + border-radius: 5px; + padding: 7px 8px 7px 10px; + background: var(--paper); + color: var(--ink); +} + +.row-edit-default[hidden], +.row-edit-custom-value[hidden] { + display: none; +} + +.row-edit-default-text { + min-width: 0; + overflow-wrap: anywhere; +} + +.row-edit-default-code { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.82rem; +} + +.row-edit-custom-value { + display: grid; + grid-template-columns: minmax(0, 1fr) 7.25rem; + gap: 8px; + align-items: center; + min-height: 45px; + padding-right: 8px; +} + +.row-edit-default-button { + appearance: none; + border: 1px solid var(--rule); + border-radius: 4px; + background: #fff; + color: var(--accent); + cursor: pointer; + font: inherit; + font-size: 0.78rem; + line-height: 1.2; + padding: 6px 8px; + white-space: nowrap; + width: 100%; + align-self: center; +} + +.row-edit-default-button:hover, +.row-edit-default-button:focus { + background: #f8fafc; +} + +.row-edit-default-button:focus { + outline: 3px solid rgba(26, 86, 219, 0.12); + outline-offset: 1px; +} + +.row-edit-field-meta { + color: var(--muted); + font-size: 0.78rem; +} + +.row-edit-field-validation-error { + color: #b42318; + display: block; + margin-top: 2px; +} + +.row-edit-field-validation-error[hidden] { + display: none; +} + +.row-edit-field-meta-autocomplete { + line-height: 1.2; + min-height: 1.2em; +} + +.row-edit-fk-pk { + color: var(--ink); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} + +.row-edit-fk-link { + overflow-wrap: anywhere; +} + +.row-edit-empty { + color: var(--muted); + font-size: 0.9rem; + margin: 0; +} + +datasette-autocomplete { + display: block; + position: relative; + max-width: 38rem; +} + +datasette-autocomplete input[type="text"], +.debug-autocomplete-form input[type="text"] { + box-sizing: border-box; + width: 100%; + max-width: 38rem; +} + +.datasette-autocomplete-list { + background: #fff; + border: 1px solid var(--rule); + border-radius: 5px; + box-shadow: 0 12px 24px rgba(15, 23, 42, 0.14); + box-sizing: border-box; + left: 0; + max-height: 16rem; + overflow-y: auto; + position: fixed; + right: auto; + top: auto; + z-index: 10000; +} + +.datasette-autocomplete-list[hidden] { + display: none; +} + +.datasette-autocomplete-option { + cursor: pointer; + padding: 7px 9px; +} + +.datasette-autocomplete-option:hover, +.datasette-autocomplete-option[aria-selected="true"] { + background: var(--paper); +} + +.datasette-autocomplete-option[aria-selected="true"] { + background: var(--paper); + font-weight: 600; +} + +.datasette-autocomplete-status { + border: 0; + clip: rect(0 0 0 0); + height: 1px; + margin: -1px; + overflow: hidden; + padding: 0; + position: absolute; + white-space: nowrap; + width: 1px; +} + +.debug-autocomplete-demo { + margin: 1rem 0; +} + +.debug-autocomplete-selected { + max-width: 46rem; +} + +.row-edit-dialog .modal-footer { + padding: 14px 20px; + border-top: 1px solid var(--rule); + display: flex; + align-items: center; + justify-content: flex-end; + gap: 10px; + flex-shrink: 0; + background: var(--paper); +} + +.row-edit-dialog .btn { + border: none; + border-radius: 5px; + padding: 9px 20px; + font-size: 0.85rem; + font-weight: 500; + cursor: pointer; + touch-action: manipulation; + font-family: inherit; + transition: background 0.12s; +} + +.row-edit-dialog .btn-ghost { + background: transparent; + color: var(--muted); + border: 1px solid var(--rule); +} + +.row-edit-dialog .btn-ghost:hover { + background: var(--rule); + color: var(--ink); +} + +.row-edit-dialog .btn-primary { + background: var(--accent); + color: #fff; +} + +.row-edit-dialog .btn-primary:hover { + background: #1949b8; +} + +.row-edit-dialog .btn:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +dialog.table-create-dialog { + --ink: #0f0f0f; + --paper: #eef6ff; + --muted: #6b6b6b; + --rule: #d8e6f5; + --accent: #1a56db; + --card: #ffffff; + border: none; + border-radius: var(--modal-border-radius, 0.75rem); + padding: 0; + margin: auto; + width: min(980px, calc(100vw - 32px)); + max-width: 95vw; + max-height: min(780px, calc(100vh - 32px)); + box-shadow: var(--modal-shadow, 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)); + animation: datasette-modal-slide-in var(--modal-animation-duration, 0.2s) ease-out; + overflow: hidden; + font-family: system-ui, -apple-system, sans-serif; + background: var(--card); +} + +dialog.table-create-dialog[open] { + display: flex; + flex-direction: column; +} + +dialog.table-create-dialog::backdrop { + background: var(--modal-backdrop-bg, rgba(0, 0, 0, 0.5)); + backdrop-filter: var(--modal-backdrop-blur, blur(4px)); + -webkit-backdrop-filter: var(--modal-backdrop-blur, blur(4px)); + animation: datasette-modal-fade-in var(--modal-animation-duration, 0.2s) ease-out; +} + +.table-create-dialog .modal-header { + padding: 20px 24px 12px; + border-bottom: 1px solid var(--rule); + display: flex; + align-items: center; + gap: 12px; + flex-shrink: 0; + min-width: 0; +} + +.table-create-dialog .modal-title { + display: flex; + align-items: center; + min-width: 0; + max-width: 100%; + font-size: 1rem; + font-weight: 600; + color: var(--ink); +} + +.table-create-form { + display: flex; + flex: 1 1 auto; + min-height: 0; + flex-direction: column; +} + +.table-create-error { + border-left: 4px solid #b91c1c; + border-radius: 4px; + background: #fff1f1; + color: #7f1d1d; + font-size: 0.9rem; + margin: 12px 24px 0; + padding: 10px 12px; +} + +.table-create-error:focus { + outline: 3px solid rgba(185, 28, 28, 0.18); + outline-offset: 2px; +} + +.table-create-fields { + display: grid; + gap: 18px; + padding: 16px 24px 24px; + overflow-y: auto; +} + +.table-create-field { + display: grid; + grid-template-columns: minmax(120px, 180px) minmax(0, 1fr); + gap: 12px; + align-items: start; +} + +.table-create-label, +.table-create-column-headings { + color: var(--ink); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.82rem; +} + +.table-create-label { + padding-top: 8px; +} + +.table-create-column-label { + border: 0; + clip: rect(0 0 0 0); + height: 1px; + margin: -1px; + overflow: hidden; + padding: 0; + position: absolute; + white-space: nowrap; + width: 1px; +} + +.table-create-input { + box-sizing: border-box; + min-width: 0; + min-height: 46px; + border: 1px solid var(--rule); + border-radius: 5px; + padding: 8px 10px; + color: var(--ink); + background: #fff; + font: inherit; + line-height: 1.35; +} + +select.table-create-input { + height: 46px; +} + +.table-create-input-placeholder { + color: var(--muted); +} + +.table-create-foreign-key-target option, +.table-create-custom-column-type option, +.table-create-default-expr option { + color: var(--ink); +} + +.table-create-foreign-key-target option[value=""], +.table-create-custom-column-type option[value=""], +.table-create-default-expr option[value=""] { + color: var(--muted); +} + +.table-create-table-name { + width: 100%; +} + +.table-create-input:focus { + border-color: var(--accent); + outline: 3px solid rgba(26, 86, 219, 0.12); +} + +.table-create-columns { + display: grid; + gap: 10px; +} + +.table-create-column-list { + display: grid; + gap: 8px; +} + +.table-create-column-headings, +.table-create-column-main { + display: grid; + grid-template-columns: minmax(140px, 1.2fr) minmax(7.5rem, 0.7fr) max-content 32px; + align-items: center; + gap: 8px; + min-width: 0; +} + +.table-create-column-row { + display: grid; + gap: 8px; + min-width: 0; +} + +.table-create-column-headings { + color: var(--muted); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.72rem; + padding: 0 1px; +} + +.table-create-column-details { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + align-items: start; + gap: 12px 16px; + padding: 12px; + border-left: 3px solid var(--rule); + background: #f8fafc; +} + +.table-create-column-details[hidden] { + display: none; +} + +.table-create-detail-field { + display: grid; + gap: 4px; + min-width: 0; +} + +.table-create-detail-label { + color: var(--muted); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.72rem; +} + +.table-create-detail-help, +.table-alter-detail-help { + color: var(--muted); + font-size: 0.82rem; + line-height: 1.35; + margin: 0; +} + +.table-create-detail-check { + display: inline-flex; + align-items: flex-start; + gap: 8px; + color: var(--ink); + font-size: 0.85rem; + line-height: 1.35; + min-width: 0; + white-space: normal; +} + +.table-create-not-null, +.table-create-primary-key, +.table-create-foreign-key-field, +.table-create-default-options { + grid-column: 1 / -1; +} + +.table-create-default-options, +.table-alter-default-options { + border: 1px solid var(--rule); + border-radius: 5px; + background: #fff; + color: var(--ink); + min-width: 0; +} + +.table-create-default-options > summary, +.table-alter-default-options > summary { + cursor: pointer; + color: var(--accent); + font-size: 0.85rem; + padding: 8px 10px; +} + +.table-create-default-options > summary:focus, +.table-alter-default-options > summary:focus { + outline: 3px solid rgba(26, 86, 219, 0.12); + outline-offset: 1px; +} + +.table-create-default-grid, +.table-alter-default-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 12px 16px; + padding: 0 10px 10px; +} + +.table-create-detail-check input { + flex: 0 0 auto; + margin: 0.15rem 0 0; +} + +.table-create-detail-check span { + min-width: 0; + overflow-wrap: break-word; +} + +.table-create-move-controls { + display: grid; + grid-template-columns: repeat(4, 32px); + gap: 4px; + justify-content: start; +} + +.table-create-more-options { + appearance: none; + border: 0; + background: transparent; + color: var(--accent); + cursor: pointer; + font: inherit; + font-size: 0.85rem; + justify-self: start; + padding: 0; + grid-column: 1 / -1; + text-align: left; +} + +.table-create-more-options:hover, +.table-create-more-options:focus { + text-decoration: underline; +} + +.table-create-more-options:focus { + outline: 3px solid rgba(26, 86, 219, 0.12); + outline-offset: 2px; +} + +.table-create-more-options:disabled { + color: var(--muted); + cursor: default; + text-decoration: none; +} + +.table-create-icon-button { + appearance: none; + border: 1px solid rgba(74, 85, 104, 0.24); + background: transparent; + color: #4a5568; + border-radius: 4px; + cursor: pointer; + display: inline-grid; + place-items: center; + height: 32px; + width: 32px; + padding: 0; +} + +.table-create-icon-button:hover, +.table-create-icon-button:focus { + background: rgba(74, 85, 104, 0.07); +} + +.table-create-icon-button:focus { + outline: 3px solid #b3d4ff; + outline-offset: 1px; +} + +.table-create-icon-button svg { + display: block; +} + +.table-create-add-column { + appearance: none; + justify-self: start; + border: 1px solid var(--rule); + border-radius: 5px; + background: #fff; + color: var(--accent); + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 6px; + font: inherit; + font-size: 0.85rem; + padding: 7px 10px; +} + +.table-create-add-column svg { + display: block; + flex: 0 0 auto; +} + +.table-create-add-column:hover, +.table-create-add-column:focus { + background: #f8fafc; +} + +.table-create-add-column:focus { + outline: 3px solid rgba(26, 86, 219, 0.12); + outline-offset: 1px; +} + +.table-create-dialog .modal-footer { + padding: 14px 20px; + border-top: 1px solid var(--rule); + display: flex; + align-items: center; + justify-content: flex-end; + gap: 10px; + flex-shrink: 0; + background: var(--paper); +} + +.table-create-dialog .btn { + border: none; + border-radius: 5px; + padding: 9px 20px; + font-size: 0.85rem; + font-weight: 500; + cursor: pointer; + touch-action: manipulation; + font-family: inherit; + transition: background 0.12s; +} + +.table-create-dialog .btn-ghost { + background: transparent; + color: var(--muted); + border: 1px solid var(--rule); +} + +.table-create-dialog .btn-ghost:hover { + background: var(--rule); + color: var(--ink); +} + +.table-create-dialog .btn-primary { + background: var(--accent); + color: #fff; +} + +.table-create-dialog .btn-primary:hover { + background: #1949b8; +} + +.table-create-dialog .btn:disabled, +.table-create-add-column:disabled, +.table-create-icon-button:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +dialog.table-alter-dialog { + --ink: #0f0f0f; + --paper: #eef6ff; + --muted: #6b6b6b; + --rule: #d8e6f5; + --accent: #1a56db; + --card: #ffffff; + border: none; + border-radius: var(--modal-border-radius, 0.75rem); + padding: 0; + margin: auto; + width: min(980px, calc(100vw - 32px)); + max-width: 95vw; + max-height: min(780px, calc(100vh - 32px)); + box-shadow: var(--modal-shadow, 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)); + animation: datasette-modal-slide-in var(--modal-animation-duration, 0.2s) ease-out; + overflow: hidden; + font-family: system-ui, -apple-system, sans-serif; + background: var(--card); +} + +dialog.table-alter-dialog[open] { + display: flex; + flex-direction: column; +} + +dialog.table-alter-dialog::backdrop { + background: var(--modal-backdrop-bg, rgba(0, 0, 0, 0.5)); + backdrop-filter: var(--modal-backdrop-blur, blur(4px)); + -webkit-backdrop-filter: var(--modal-backdrop-blur, blur(4px)); + animation: datasette-modal-fade-in var(--modal-animation-duration, 0.2s) ease-out; +} + +.table-alter-dialog .modal-header { + padding: 20px 24px 12px; + border-bottom: 1px solid var(--rule); + display: flex; + align-items: center; + gap: 12px; + flex-shrink: 0; + min-width: 0; +} + +.table-alter-dialog .modal-title { + display: flex; + align-items: center; + min-width: 0; + max-width: 100%; + font-size: 1rem; + font-weight: 600; + color: var(--ink); +} + +.table-alter-form { + display: flex; + flex: 1 1 auto; + min-height: 0; + flex-direction: column; +} + +.table-alter-error { + border-left: 4px solid #b91c1c; + border-radius: 4px; + background: #fff1f1; + color: #7f1d1d; + font-size: 0.9rem; + margin: 12px 24px 0; + padding: 10px 12px; +} + +.table-alter-error:focus { + outline: 3px solid rgba(185, 28, 28, 0.18); + outline-offset: 2px; +} + +.table-alter-fields { + display: grid; + gap: 18px; + padding: 16px 24px 24px; + overflow-y: auto; +} + +.table-alter-table-options { + border-top: 1px solid var(--rule); + padding-top: 12px; +} + +.table-alter-table-options > summary { + color: var(--ink); + cursor: pointer; + font-weight: 600; +} + +.table-alter-table-options > summary:focus { + outline: 3px solid rgba(26, 86, 219, 0.12); + outline-offset: 3px; +} + +.table-alter-table-name-field { + display: grid; + gap: 4px; + margin-top: 10px; + max-width: 24rem; +} + +.table-alter-fields[hidden], +.table-alter-dialog .modal-footer [hidden] { + display: none; +} + +.table-alter-review { + display: grid; + gap: 12px; + overflow-y: auto; + padding: 16px 24px 24px; +} + +.table-alter-review[hidden] { + display: none; +} + +.table-alter-review-title { + color: var(--ink); + font-size: 1rem; + line-height: 1.35; + margin: 0; +} + +.table-alter-review-title:focus { + outline: 3px solid rgba(26, 86, 219, 0.12); + outline-offset: 2px; +} + +.table-alter-review-intro { + color: var(--muted); + font-size: 0.9rem; + margin: 0; +} + +.table-alter-review-warning { + border-left: 4px solid #b91c1c; + border-radius: 4px; + background: #fff1f1; + color: #7f1d1d; + font-size: 0.9rem; + margin: 0; + padding: 10px 12px; +} + +.table-alter-review-list { + display: grid; + gap: 8px; + margin: 0; + padding-left: 1.4rem; +} + +.table-alter-review-list li { + color: var(--ink); + line-height: 1.4; +} + +.table-alter-review-damaging { + font-weight: 600; +} + +.table-alter-review-name { + background: #eef6ff; + border: 1px solid #c9ddf2; + border-radius: 4px; + color: var(--ink); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.85em; + padding: 1px 4px; + white-space: nowrap; +} + +.table-alter-columns { + display: grid; + gap: 10px; +} + +.table-alter-column-list { + display: grid; + gap: 8px; +} + +.table-alter-column-headings, +.table-alter-column-main { + display: grid; + grid-template-columns: minmax(140px, 1.2fr) minmax(7.5rem, 0.7fr) max-content 32px; + align-items: center; + gap: 8px; + min-width: 0; +} + +.table-alter-column-row { + display: grid; + gap: 8px; + min-width: 0; +} + +.table-alter-column-headings { + color: var(--muted); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.72rem; + padding: 0 1px; +} + +.table-alter-column-label { + border: 0; + clip: rect(0 0 0 0); + height: 1px; + margin: -1px; + overflow: hidden; + padding: 0; + position: absolute; + white-space: nowrap; + width: 1px; +} + +.table-alter-input { + box-sizing: border-box; + min-width: 0; + min-height: 46px; + border: 1px solid var(--rule); + border-radius: 5px; + padding: 8px 10px; + color: var(--ink); + background: #fff; + font: inherit; + line-height: 1.35; +} + +select.table-alter-input { + height: 46px; +} + +.table-alter-input-placeholder { + color: var(--muted); +} + +.table-alter-default-expr option, +.table-alter-custom-column-type option, +.table-alter-foreign-key-target option { + color: var(--ink); +} + +.table-alter-default-expr option[value=""], +.table-alter-custom-column-type option[value=""], +.table-alter-foreign-key-target option[value=""] { + color: var(--muted); +} + +.table-alter-input:focus { + border-color: var(--accent); + outline: 3px solid rgba(26, 86, 219, 0.12); +} + +.table-alter-column-details { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + align-items: start; + gap: 12px 16px; + padding: 12px; + border-left: 3px solid var(--rule); + background: #f8fafc; +} + +.table-alter-column-details[hidden] { + display: none; +} + +.table-alter-detail-field { + display: grid; + gap: 4px; + min-width: 0; +} + +.table-alter-detail-label { + color: var(--muted); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.72rem; +} + +.table-alter-detail-check { + display: inline-flex; + align-items: flex-start; + gap: 8px; + color: var(--ink); + font-size: 0.85rem; + line-height: 1.35; + min-width: 0; + white-space: normal; +} + +.table-alter-not-null, +.table-alter-primary-key, +.table-alter-foreign-key-field, +.table-alter-default-options { + grid-column: 1 / -1; +} + +.table-alter-detail-check input { + flex: 0 0 auto; + margin: 0.15rem 0 0; +} + +.table-alter-detail-check span { + min-width: 0; + overflow-wrap: break-word; +} + +.table-alter-move-controls { + display: grid; + grid-template-columns: repeat(4, 32px); + gap: 4px; + justify-content: start; +} + +.table-alter-more-options { + appearance: none; + border: 0; + background: transparent; + color: var(--accent); + cursor: pointer; + font: inherit; + font-size: 0.85rem; + justify-self: start; + padding: 0; + grid-column: 1 / -1; + text-align: left; +} + +.table-alter-more-options:hover, +.table-alter-more-options:focus { + text-decoration: underline; +} + +.table-alter-more-options:focus { + outline: 3px solid rgba(26, 86, 219, 0.12); + outline-offset: 2px; +} + +.table-alter-more-options:disabled { + color: var(--muted); + cursor: default; + text-decoration: none; +} + +.table-alter-icon-button { + appearance: none; + border: 1px solid rgba(74, 85, 104, 0.24); + background: transparent; + color: #4a5568; + border-radius: 4px; + cursor: pointer; + display: inline-grid; + place-items: center; + height: 32px; + width: 32px; + padding: 0; +} + +.table-alter-icon-button:hover, +.table-alter-icon-button:focus { + background: rgba(74, 85, 104, 0.07); +} + +.table-alter-icon-button:focus { + outline: 3px solid #b3d4ff; + outline-offset: 1px; +} + +.table-alter-icon-button svg { + display: block; +} + +.table-alter-add-column { + appearance: none; + justify-self: start; + border: 1px solid var(--rule); + border-radius: 5px; + background: #fff; + color: var(--accent); + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 6px; + font: inherit; + font-size: 0.85rem; + padding: 7px 10px; +} + +.table-alter-add-column svg { + display: block; + flex: 0 0 auto; +} + +.table-alter-add-column:hover, +.table-alter-add-column:focus { + background: #f8fafc; +} + +.table-alter-add-column:focus { + outline: 3px solid rgba(26, 86, 219, 0.12); + outline-offset: 1px; +} + +.table-alter-dialog .modal-footer { + padding: 14px 20px; + border-top: 1px solid var(--rule); + display: flex; + align-items: center; + justify-content: flex-end; + gap: 10px; + flex-shrink: 0; + background: var(--paper); +} + +.table-alter-dialog .btn { + border: none; + border-radius: 5px; + padding: 9px 20px; + font-size: 0.85rem; + font-weight: 500; + cursor: pointer; + touch-action: manipulation; + font-family: inherit; + transition: background 0.12s; +} + +.table-alter-dialog .btn-ghost { + background: transparent; + color: var(--muted); + border: 1px solid var(--rule); +} + +.table-alter-dialog .btn-ghost:hover { + background: var(--rule); + color: var(--ink); +} + +.table-alter-dialog .btn-danger { + background: #b91c1c; + color: #fff; + margin-right: auto; +} + +.table-alter-dialog .btn-danger:hover { + background: #991b1b; +} + +.table-alter-dialog .btn-danger:disabled, +.table-alter-dialog .btn-danger:disabled:hover { + background: #d98c8c; + color: #fff; +} + +.table-alter-dialog .btn-primary { + background: var(--accent); + color: #fff; +} + +.table-alter-dialog .btn-primary:hover { + background: #1949b8; +} + +.table-alter-dialog .btn-primary:disabled, +.table-alter-dialog .btn-primary:disabled:hover { + background: #a0aec0; + color: #fff; +} + +.table-alter-dialog .btn:disabled, +.table-alter-add-column:disabled, +.table-alter-icon-button:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +@media (max-width: 900px) { + dialog.table-alter-dialog { + width: 95vw; + max-height: 85vh; + border-radius: 0.5rem; + } + + .table-alter-dialog .modal-header, + .table-alter-fields, + .table-alter-review { + padding-left: 18px; + padding-right: 18px; + } + + .table-alter-error { + margin-left: 18px; + margin-right: 18px; + } + + .table-alter-column-headings { + display: none; + } + + .table-alter-column-row { + padding-bottom: 8px; + border-bottom: 1px solid var(--rule); + } + + .table-alter-column-main { + grid-template-columns: minmax(0, 1fr) minmax(7.5rem, 0.8fr) 32px; + align-items: end; + } + + .table-alter-column-name { + grid-column: 1; + grid-row: 1; + } + + .table-alter-column-type { + grid-column: 2; + grid-row: 1; + } + + .table-alter-remove-column { + grid-column: 3; + grid-row: 1; + justify-self: end; + } + + .table-alter-move-controls { + grid-column: 1; + grid-row: 2; + justify-self: start; + } + + .table-alter-more-options { + align-self: center; + grid-column: 2 / 4; + grid-row: 2; + } + + .table-alter-column-details { + grid-template-columns: 1fr; + } + + .table-alter-default-grid { + grid-template-columns: 1fr; + } + + .table-alter-dialog .modal-footer { + padding-left: 18px; + padding-right: 18px; + } +} + +.row-link-with-actions { + display: inline-flex; + align-items: center; + gap: 0.4rem; + flex-wrap: wrap; +} + +.row-inline-actions { + display: inline-flex; + gap: 0.2rem; + align-items: center; +} + +.row-inline-action { + appearance: none; + border: 1px solid rgba(74, 85, 104, 0.24); + background: transparent; + color: #4a5568; + border-radius: 4px; + cursor: pointer; + display: inline-grid; + place-items: center; + min-height: 24px; + min-width: 24px; + padding: 2px; + position: relative; +} + +.row-inline-action:hover, +.row-inline-action:focus { + background: rgba(74, 85, 104, 0.07); +} + +.row-inline-action:focus { + outline: 3px solid #b3d4ff; + outline-offset: 1px; +} + +.row-inline-action-icon { + display: block; + height: 13px; + width: 13px; +} + @media (max-width: 640px) { dialog.mobile-column-actions-dialog { width: 95vw; @@ -1239,6 +2984,149 @@ dialog.set-column-type-dialog::backdrop { padding-left: 18px; padding-right: 18px; } + + dialog.row-delete-dialog { + width: 95vw; + max-height: 85vh; + border-radius: 0.5rem; + } + + .row-delete-dialog .modal-header, + .row-delete-message, + .row-delete-error { + padding-left: 18px; + padding-right: 18px; + } + + .row-delete-dialog .modal-footer { + padding-left: 18px; + padding-right: 18px; + } + + dialog.row-edit-dialog { + width: 95vw; + max-height: 85vh; + border-radius: 0.5rem; + } + + .row-edit-dialog .modal-header, + .row-edit-summary, + .row-edit-loading, + .row-edit-fields { + padding-left: 18px; + padding-right: 18px; + } + + .row-edit-error { + margin-left: 18px; + margin-right: 18px; + } + + .row-edit-field { + grid-template-columns: 1fr; + gap: 5px; + } + + .row-edit-label { + padding-top: 0; + } + + .row-edit-dialog .modal-footer { + padding-left: 18px; + padding-right: 18px; + } + + dialog.table-create-dialog { + width: 95vw; + max-height: 85vh; + border-radius: 0.5rem; + } + + .table-create-dialog .modal-header, + .table-create-fields { + padding-left: 18px; + padding-right: 18px; + } + + .table-create-error { + margin-left: 18px; + margin-right: 18px; + } + + .table-create-field { + grid-template-columns: 1fr; + gap: 5px; + } + + .table-create-label { + padding-top: 0; + } + + .table-create-column-headings { + display: none; + } + + .table-create-column-row { + padding-bottom: 8px; + border-bottom: 1px solid var(--rule); + } + + .table-create-column-main { + grid-template-columns: minmax(0, 1fr) minmax(7.5rem, 0.8fr) 32px; + align-items: end; + } + + .table-create-column-name { + grid-column: 1; + grid-row: 1; + } + + .table-create-column-type { + grid-column: 2; + grid-row: 1; + } + + .table-create-remove-column { + grid-column: 3; + grid-row: 1; + justify-self: end; + } + + .table-create-move-controls { + grid-column: 1; + grid-row: 2; + justify-self: start; + } + + .table-create-more-options { + align-self: center; + grid-column: 2 / 4; + grid-row: 2; + } + + .table-create-column-details { + grid-template-columns: 1fr; + } + + .table-create-default-grid { + grid-template-columns: 1fr; + } + + .table-create-dialog .modal-footer { + padding-left: 18px; + padding-right: 18px; + } + + .row-inline-action { + min-height: 30px; + min-width: 30px; + padding: 4px; + } + + .row-inline-action-icon { + height: 14px; + width: 14px; + } } @media only screen and (max-width: 576px) { @@ -1293,16 +3181,63 @@ dialog.set-column-type-dialog::backdrop { font-size: 0.8em; } - .select-wrapper { - width: 100px; + .row-inline-actions { + margin-bottom: 0.35rem; } - .select-wrapper.filter-op { - width: 60px; + + .filters { + max-width: none; + padding: 0.65rem; + } + .filters .search-row { + grid-template-columns: max-content minmax(0, 1fr); + padding-right: 0; + } + .filters .filter-controls-row { + --filter-value-button-space: 0px; + --filter-remove-button-offset: 0px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .filters .filter-controls-row-one-button { + --filter-value-button-space: calc( + var(--filter-row-icon-size) + var(--filter-control-gap) + ); + } + .filters .filter-controls-row-two-buttons { + --filter-value-button-space: var(--filter-two-icon-space); + --filter-remove-button-offset: calc( + var(--filter-row-icon-size) + var(--filter-control-gap) + ); + } + .filters .filter-controls-row .select-wrapper { + grid-row: 1; + grid-column: 1 / 2; + } + .filters .filter-controls-row .select-wrapper.filter-op { + grid-column: 2 / 3; } .filters input.filter-value { - width: 140px; + grid-row: 2; + grid-column: 1 / 3; + justify-self: start; + width: calc(100% - var(--filter-value-button-space)); + } + .filters button.filter-row-icon { + grid-row: 2; + grid-column: 1 / 3; + justify-self: end; + } + .filters .filter-controls-row-two-buttons button.filter-row-remove:not([hidden]) { + margin-right: var(--filter-remove-button-offset); + } + .filters .filter-actions-row { + justify-content: flex-start; + } + .filters .filter-actions-row input[type=submit] { + flex: 0 1 auto; } button.choose-columns-mobile, + button.table-insert-row, button.column-actions-mobile { display: inline-flex; align-items: center; @@ -1339,6 +3274,15 @@ dialog.set-column-type-dialog::backdrop { button.choose-columns-mobile { margin-right: 0.5rem; } + + .table-row-toolbar { + margin-bottom: 0.75rem; + } + + button.table-insert-row { + width: 100%; + margin-bottom: 0; + } } svg.dropdown-menu-icon { @@ -1384,18 +3328,32 @@ svg.dropdown-menu-icon { .dropdown-menu a:link, .dropdown-menu a:visited, .dropdown-menu a:hover, -.dropdown-menu a:focus -.dropdown-menu a:active { +.dropdown-menu a:focus, +.dropdown-menu a:active, +.dropdown-menu button.action-menu-button { text-decoration: none; display: block; padding: 4px 8px 2px 8px; color: #222; white-space: nowrap; } -.dropdown-menu a:hover { +.dropdown-menu button.action-menu-button { + appearance: none; + background: none; + border: none; + box-sizing: border-box; + cursor: pointer; + font: inherit; + text-align: left; + width: 100%; +} +.dropdown-menu a:hover, +.dropdown-menu button.action-menu-button:hover, +.dropdown-menu button.action-menu-button:focus { background-color: #eee; } .dropdown-menu .dropdown-description { + display: block; margin: 0; color: #666; font-size: 0.8em; diff --git a/datasette/static/autocomplete.js b/datasette/static/autocomplete.js new file mode 100644 index 00000000..c615000e --- /dev/null +++ b/datasette/static/autocomplete.js @@ -0,0 +1,344 @@ +(function () { + function autocompleteValueFromRow(row) { + var pks = (row && row.pks) || {}; + var keys = Object.keys(pks); + if (!keys.length) { + return ""; + } + if (keys.length === 1) { + return String(pks[keys[0]]); + } + return keys + .map(function (key) { + return key + "=" + pks[key]; + }) + .join(", "); + } + + function autocompleteLabelFromRow(row) { + var value = autocompleteValueFromRow(row); + if (row.label && String(row.label) !== value) { + return row.label + " (" + value + ")"; + } + return value; + } + + if (!window.customElements || customElements.get("datasette-autocomplete")) { + return; + } + + class DatasetteAutocomplete extends HTMLElement { + constructor() { + super(); + this.input = null; + this.listbox = null; + this.status = null; + this.results = []; + this.activeIndex = -1; + this.fetchId = 0; + this.searchTimer = null; + this.boundInput = this.handleInput.bind(this); + this.boundKeydown = this.handleKeydown.bind(this); + this.boundBlur = this.handleBlur.bind(this); + this.boundFocus = this.handleFocus.bind(this); + this.boundPositionListbox = this.positionListbox.bind(this); + } + + connectedCallback() { + if (this.input) { + return; + } + this.input = this.querySelector("input"); + if (!this.input) { + return; + } + + var inputId = + this.input.id || + "datasette-autocomplete-" + Math.random().toString(36).slice(2); + this.input.id = inputId; + var listboxId = inputId + "-listbox"; + var statusId = inputId + "-status"; + + this.classList.add("datasette-autocomplete"); + this.input.setAttribute("role", "combobox"); + this.input.setAttribute("aria-autocomplete", "list"); + this.input.setAttribute("aria-expanded", "false"); + this.input.setAttribute("aria-controls", listboxId); + this.input.setAttribute("autocomplete", "off"); + + this.listbox = document.createElement("div"); + this.listbox.className = "datasette-autocomplete-list"; + this.listbox.id = listboxId; + this.listbox.setAttribute("role", "listbox"); + this.listbox.hidden = true; + + this.status = document.createElement("span"); + this.status.className = "datasette-autocomplete-status"; + this.status.id = statusId; + this.status.setAttribute("role", "status"); + this.status.setAttribute("aria-live", "polite"); + + this.input.setAttribute( + "aria-describedby", + [this.input.getAttribute("aria-describedby"), statusId] + .filter(Boolean) + .join(" "), + ); + + this.appendChild(this.listbox); + this.appendChild(this.status); + + this.input.addEventListener("input", this.boundInput); + this.input.addEventListener("keydown", this.boundKeydown); + this.input.addEventListener("blur", this.boundBlur); + this.input.addEventListener("focus", this.boundFocus); + } + + disconnectedCallback() { + if (!this.input) { + return; + } + this.input.removeEventListener("input", this.boundInput); + this.input.removeEventListener("keydown", this.boundKeydown); + this.input.removeEventListener("blur", this.boundBlur); + this.input.removeEventListener("focus", this.boundFocus); + } + + handleInput() { + this.scheduleSearch(); + } + + handleFocus() { + if (this.input.value.trim() || this.hasAttribute("suggest-on-focus")) { + this.scheduleSearch(); + } + } + + handleBlur() { + window.setTimeout(() => this.close(), 150); + } + + handleKeydown(ev) { + if (ev.key === "Escape") { + if (!this.listbox.hidden) { + ev.preventDefault(); + this.close(); + } + return; + } + if (ev.key === "ArrowDown") { + ev.preventDefault(); + if (this.listbox.hidden) { + this.scheduleSearch(); + } else { + this.setActiveIndex(this.activeIndex + 1); + } + return; + } + if (ev.key === "ArrowUp") { + ev.preventDefault(); + if (!this.listbox.hidden) { + this.setActiveIndex(this.activeIndex - 1); + } + return; + } + if (ev.key === "Enter" && !this.listbox.hidden && this.activeIndex >= 0) { + ev.preventDefault(); + this.chooseIndex(this.activeIndex); + } + } + + scheduleSearch() { + window.clearTimeout(this.searchTimer); + this.searchTimer = window.setTimeout(() => this.search(), 150); + } + + async search() { + var query = this.input.value.trim(); + var initial = !query && this.hasAttribute("suggest-on-focus"); + if (!query && !initial) { + this.close(); + this.status.textContent = ""; + return; + } + var src = this.getAttribute("src"); + if (!src) { + return; + } + + var url = new URL(src, location.href); + url.searchParams.set("q", query); + if (initial) { + url.searchParams.set("_initial", "1"); + } else { + url.searchParams.delete("_initial"); + } + var fetchId = this.fetchId + 1; + this.fetchId = fetchId; + this.status.textContent = "Searching..."; + + try { + var response = await fetch(url.toString(), { + headers: { + Accept: "application/json", + }, + }); + if (!response.ok) { + throw new Error("HTTP " + response.status); + } + var data = await response.json(); + if (fetchId !== this.fetchId) { + return; + } + this.results = (data && data.rows) || []; + this.render(); + } catch (_error) { + if (fetchId !== this.fetchId) { + return; + } + this.results = []; + this.close(); + this.status.textContent = "Could not load suggestions"; + } + } + + render() { + this.listbox.textContent = ""; + this.activeIndex = -1; + if (!this.results.length) { + this.close(); + this.status.textContent = "No matches"; + return; + } + + this.results.forEach((row, index) => { + var option = document.createElement("div"); + option.className = "datasette-autocomplete-option"; + option.id = this.input.id + "-option-" + index; + option.setAttribute("role", "option"); + option.setAttribute("aria-selected", "false"); + option.dataset.index = String(index); + option.dataset.value = autocompleteValueFromRow(row); + option.textContent = autocompleteLabelFromRow(row); + option.addEventListener("mousedown", (ev) => { + ev.preventDefault(); + this.chooseIndex(index); + }); + this.listbox.appendChild(option); + }); + + this.listbox.hidden = false; + this.input.setAttribute("aria-expanded", "true"); + this.status.textContent = + this.results.length + (this.results.length === 1 ? " match" : " matches"); + this.positionListbox(); + this.setActiveIndex(0); + } + + positionListbox() { + if (!this.input || !this.listbox || this.listbox.hidden) { + return; + } + + var gap = 3; + var margin = 8; + var inputRect = this.input.getBoundingClientRect(); + this.listbox.style.maxHeight = ""; + var defaultMaxHeight = parseFloat( + window.getComputedStyle(this.listbox).maxHeight, + ); + if (!Number.isFinite(defaultMaxHeight)) { + defaultMaxHeight = 256; + } + var scrollHeight = Math.ceil(this.listbox.scrollHeight); + var desiredHeight = Math.min(scrollHeight, defaultMaxHeight); + var availableBelow = Math.max( + 0, + (window.innerHeight || document.documentElement.clientHeight) - + inputRect.bottom - + gap - + margin, + ); + + this.listbox.style.left = inputRect.left + "px"; + this.listbox.style.top = inputRect.bottom + gap + "px"; + this.listbox.style.width = inputRect.width + "px"; + if (scrollHeight <= defaultMaxHeight && scrollHeight <= availableBelow) { + this.listbox.style.maxHeight = "none"; + } else { + this.listbox.style.maxHeight = + Math.min(defaultMaxHeight, desiredHeight, availableBelow || defaultMaxHeight) + + "px"; + } + window.addEventListener("resize", this.boundPositionListbox); + document.addEventListener("scroll", this.boundPositionListbox, true); + } + + setActiveIndex(index) { + var options = this.listbox.querySelectorAll("[role='option']"); + if (!options.length) { + this.activeIndex = -1; + this.input.removeAttribute("aria-activedescendant"); + return; + } + if (index < 0) { + index = options.length - 1; + } + if (index >= options.length) { + index = 0; + } + options.forEach((option, optionIndex) => { + option.setAttribute( + "aria-selected", + optionIndex === index ? "true" : "false", + ); + }); + this.activeIndex = index; + this.input.setAttribute("aria-activedescendant", options[index].id); + } + + chooseIndex(index) { + var row = this.results[index]; + if (!row) { + return; + } + var value = autocompleteValueFromRow(row); + var label = autocompleteLabelFromRow(row); + this.input.value = value; + this.input.dispatchEvent(new Event("change", { bubbles: true })); + this.close(); + this.status.textContent = "Selected " + label; + this.dispatchEvent( + new CustomEvent("datasette-autocomplete-select", { + bubbles: true, + detail: { + row: row, + value: value, + label: label, + }, + }), + ); + } + + close() { + if (this.listbox) { + this.listbox.hidden = true; + this.listbox.textContent = ""; + this.listbox.style.left = ""; + this.listbox.style.maxHeight = ""; + this.listbox.style.top = ""; + this.listbox.style.width = ""; + } + if (this.input) { + this.input.setAttribute("aria-expanded", "false"); + this.input.removeAttribute("aria-activedescendant"); + } + window.removeEventListener("resize", this.boundPositionListbox); + document.removeEventListener("scroll", this.boundPositionListbox, true); + this.activeIndex = -1; + } + } + + customElements.define("datasette-autocomplete", DatasetteAutocomplete); +})(); diff --git a/datasette/static/column-chooser.js b/datasette/static/column-chooser.js index 133e7cb0..198641f3 100644 --- a/datasette/static/column-chooser.js +++ b/datasette/static/column-chooser.js @@ -31,9 +31,9 @@ class ColumnChooser extends HTMLElement {